Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
217    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
218    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219    /// Provider session id to resume (node-start restore of journalled sessions).
220    static RESUME_PROVIDER_SESSION: Option<String>;
221}
222
223/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
224fn spawn_selection(
225    options: &gate4agent_node_protocol::SpawnSessionOptions,
226) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
227    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
228    match (&options.model, &options.effort) {
229        (None, None) => Ok(None),
230        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
231        (Some(model), effort) => {
232            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
233            if let Some(e) = effort {
234                sel = sel.with_value("effort", e.clone());
235            }
236            Ok(Some(sel))
237        }
238    }
239}
240
241mod browser_bridge;
242
243use gate4agent_node_core::drive_loop_profile;
244
245#[path = "bundle_delivery.rs"]
246mod bundle_delivery;
247
248#[path = "context_pack_store.rs"]
249mod context_pack_store;
250
251#[cfg(windows)]
252pub use crate::platform::DEFAULT_NODE_ENDPOINT;
253
254const NODE_EVENT_HISTORY_MAX: usize = 4_096;
255const NODE_BROADCAST_CAPACITY: usize = 1_024;
256const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
257/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
258/// terminal frame is a full-screen snapshot; only the latest ever matters),
259/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
260/// two pending sends into one would silently drop content a subscriber can
261/// never recover. This needs headroom for a burst instead.
262const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
263/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
264/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
265/// admission check), and `publish_transient` sends each exactly once as a
266/// single-envelope batch -- so this many slots covers the entire pending
267/// set without ever lagging the one subscriber that matters, the harness's
268/// own connection, the way sharing the durable `event_tx`'s bus could.
269const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
270const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
271const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
272const MAX_PREAUTH_CONNECTIONS: usize = 32;
273const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
274const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
275const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
276const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
277const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
278/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
279/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
280/// not the disconnect reconnect grace (HQ owns that, starting only on a real
281/// client WS close). A provider conversation id is a session; a PTY with no
282/// attached HQ view is still a draft and is reaped here if attach never lands.
283const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
284/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
285/// record retention policy (`NodeServerConfig::session_record_retention`).
286/// A sweep cadence, not a policy value -- the policy itself lives entirely
287/// in the operator-configured age/keep-N flags; this just bounds how stale
288/// the in-memory view of dead `Unavailable` records can get between sweeps
289/// on an otherwise-idle node, mirroring the once-per-startup sweep that
290/// also runs alongside `reconcile_managed_worktrees`.
291const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
292/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
293/// field's own doc for why a spawn waiter needs this at all. Must outlive
294/// the longest a waiter can still be polling for this exact command's
295/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
296/// largest `deadline_ms` any caller may request, and the margin on top
297/// absorbs the waiter's own poll cadence plus clock skew between the drive
298/// loop (writer) and the waiter (reader).
299const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
300/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
301/// backlog of asynchronously-rejected commands nobody ever collects (a
302/// waiter that stops polling, or a burst of always-rejected commands from a
303/// misbehaving caller): once at capacity, the oldest entry is evicted to
304/// make room for the newest, mirroring the eviction rule already used by
305/// `spawn_idempotency`.
306const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
307/// How long `wait_until_removed` waits between two `Remove` re-dispatches
308/// for the same session. The presence poll runs far faster (2ms); this
309/// only paces the sending, so an accepted `Remove` still in flight is not
310/// piled on by a dozen more that land on an already-dropped instance.
311const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
312const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
313const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
314const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
315const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
316const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
317const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
318const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
319const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
320const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
321const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
322const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
323const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
324/// Default total wall-clock budget for `inspect_workspace`'s walk+git
325/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
326const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
327/// The harness relay enforces its own flat response deadline
328/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
329/// 12s) above this node-side budget. The two constants live in different
330/// crates with no shared definition, so clamp any override here comfortably
331/// below that external ceiling — a misconfigured value above it would
332/// reopen the exact deadline race this budget exists to close.
333const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
334/// Default cap on directory entries visited (not just returned) by one
335/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
336const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
338const GIT_STATUS_MAX_ENTRIES: usize = 128;
339const GIT_COMMIT_MAX_ENTRIES: usize = 12;
340const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
341const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
342const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
343const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
344const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
345const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
346const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
347const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
348const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
349const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
350    "provider-session-identity-allocation-failed";
351const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
352const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
353const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
354const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
355const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
356const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
357const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
358    "durable-state-path-semantics-unsupported";
359
360fn opaque_windows_path(value: String) -> OpaqueHostPath {
361    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
362}
363
364fn agent_progress_from_provider_snapshot(
365    address: SessionAddress,
366    provider: &ProviderSnapshot,
367) -> Option<SessionAgentProgress> {
368    let mut truncated = false;
369    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
370    let mut active_tool_labels = Vec::with_capacity(
371        provider
372            .active_tools
373            .len()
374            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
375    );
376    for tool in &provider.active_tools {
377        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
378            truncated = true;
379            break;
380        }
381        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
382            active_tool_labels.push(label);
383        }
384    }
385    let attention = provider
386        .interactions
387        .iter()
388        .find(|interaction| {
389            matches!(
390                interaction.status,
391                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
392            )
393        })
394        .map(|interaction| AgentProgressAttentionV1 {
395            kind: match interaction.interaction_kind {
396                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
397                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
398            },
399            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
400        });
401    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
402    let usage = (provider.completed_turns > 0
403        || provider.usage.input_tokens > 0
404        || provider.usage.output_tokens > 0
405        || provider.usage.cache_read_tokens > 0
406        || provider.usage.cache_write_tokens > 0
407        || provider.usage.reasoning_tokens > 0)
408        .then_some(AgentProgressUsageV1 {
409            input_tokens: provider.usage.input_tokens,
410            output_tokens: provider.usage.output_tokens,
411            cache_read_tokens: provider.usage.cache_read_tokens,
412            cache_write_tokens: provider.usage.cache_write_tokens,
413            reasoning_tokens: provider.usage.reasoning_tokens,
414        });
415    let progress = AgentProgressV1 {
416        provider_sequence: provider.sequence,
417        activity: provider.activity,
418        completed_turns: provider.completed_turns,
419        usage,
420        current: AgentProgressCurrentV1::from(provider.activity),
421        active_tool_labels,
422        active_tool_count,
423        attention,
424        subagent_count,
425        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
426        gap_count: provider.gap_count,
427        stale: provider.stale,
428        truncated,
429    };
430    let entry = SessionAgentProgress { address, progress };
431    serde_json::to_vec(&entry)
432        .ok()
433        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
434        .map(|_| entry)
435}
436
437fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
438    match u32::try_from(count) {
439        Ok(count) => count,
440        Err(_) => {
441            *truncated = true;
442            u32::MAX
443        }
444    }
445}
446
447fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
448    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
449        return None;
450    }
451    Some(match event {
452        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
453        ProviderEvent::SessionIdentityObserved { .. } => {
454            AgentProgressEventKindV1::SessionIdentityObserved
455        }
456        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
457        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
458        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
459        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
460        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
461        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
462        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
463        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
464        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
465        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
466        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
467        ProviderEvent::InteractionRequested { .. } => {
468            AgentProgressEventKindV1::InteractionRequested
469        }
470        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
471        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
472        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
473        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
474        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
475        ProviderEvent::UnrecognizedNotification { .. } => {
476            AgentProgressEventKindV1::UnrecognizedNotification
477        }
478        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
479        // ACP session/update coverage beyond text/tool/turn streaming
480        // (`plan`, `available_commands_update`, `current_mode_update`,
481        // `session_info_update`, `usage_update`, `config_option_update`,
482        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
483        // wire enum without a `#[serde(other)]` fallback; minting new
484        // variants for it is a deliberate wire-contract decision for
485        // whoever owns this protocol, not a side effect of parsing more of
486        // ACP's own wire -- until that decision is made, these fold into
487        // the same "we saw something, no dedicated kind for it yet" bucket
488        // as `UnrecognizedNotification`.
489        ProviderEvent::UserMessage { .. }
490        | ProviderEvent::Plan { .. }
491        | ProviderEvent::AvailableCommandsUpdated { .. }
492        | ProviderEvent::ModeChanged { .. }
493        | ProviderEvent::SessionInfoUpdated { .. }
494        | ProviderEvent::UsageUpdated { .. }
495        | ProviderEvent::ConfigOptionsUpdated { .. } => {
496            AgentProgressEventKindV1::UnrecognizedNotification
497        }
498    })
499}
500
501/// Maps this harness's own `HostDecisionAuthority` onto the wider
502/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
503/// chunk carries -- the four authorities a `Denied` decision is ever made
504/// under, one-for-one. The remaining `BlockAuthorityV1` variants
505/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
506/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
507/// block the PROVIDER side of the wire reported -- this function never
508/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
509/// them; the provider-side blocks below mint those directly from typed fields.
510/// Whether a provider source is a live session transport -- the only kind
511/// whose events reach the agent stream. History, resume, session-option and
512/// capability-probe sources are not sessions and stream nothing.
513fn provider_family_is_live(family: AdapterFamily) -> bool {
514    match family {
515        AdapterFamily::PtySemantic
516        | AdapterFamily::Hook
517        | AdapterFamily::ManagedHook
518        | AdapterFamily::Pipe
519        | AdapterFamily::OneShot
520        | AdapterFamily::Acp => true,
521        AdapterFamily::History
522        | AdapterFamily::Resume
523        | AdapterFamily::SessionOptions
524        | AdapterFamily::CapabilityProbe => false,
525    }
526}
527
528fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
529    match by {
530        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
531        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
532        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
533        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
534    }
535}
536
537/// The `Blocked::reason` for a `Denied` decision this build's own
538/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
539/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
540/// request_permission` request resolved by `AcpSession::
541/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
542/// neither of which computes free text (an operator answers approve/reject,
543/// never a comment; an unattended deadline asks nobody anything). `reason`
544/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
545/// -- a categorical fallback naming the authority is the
546/// honest minimum, never a placeholder standing in for detail nobody
547/// reported.
548fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
549    match authority {
550        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
551        BlockAuthorityV1::HostPolicy => "denied by host policy",
552        BlockAuthorityV1::HostDeadline => {
553            "denied by host policy after the operator deadline expired"
554        }
555        BlockAuthorityV1::Operator => "declined by the operator",
556        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
557        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
558        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
559        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
560        BlockAuthorityV1::UserRejected => "rejected by the user",
561        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
562        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
563    }
564}
565
566/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
567/// for a `HostRequestObserved` `Denied` decision.
568fn host_request_denied_block(
569    method: &str,
570    by: ProviderHostDecisionAuthority,
571    reason: Option<&str>,
572) -> (String, BlockAuthorityV1, String) {
573    let authority = block_authority(by);
574    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
575    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
576    // rather than truncating -- this cut happens here, at the producer, before
577    // the validator ever sees the text.
578    let (reason_text, _) =
579        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
580    (tool_class_label(method), authority, reason_text)
581}
582
583// ---------------------------------------------------------------------------
584// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
585// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
586// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
587// prompt` RPC error whose vendor code names a quota/limit/rate
588// exhaustion). See
589// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
590// §3 slice K2.
591// ---------------------------------------------------------------------------
592
593/// Classifies ACP's `nonExecutionKind` into the typed authority a block
594/// this build mints under, or `None` when the tool truly did not run for a
595/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
596/// TURN stopped (session closed, the operator cancelled), not that a
597/// policy/classifier/rule refused the call, so those (and any unrecognized
598/// future value) stay a plain `ToolCompleted { success: false }` only, per
599/// this plan's own rule that a tool failure is never inflated into a block
600/// this build cannot actually attribute.
601fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
602    match kind? {
603        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
604        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
605        _ => None,
606    }
607}
608
609/// Splits a provider's own denial sentence into `reason`/`help`, when it
610/// matches the fixed shape Claude Code's classifier/permission-rule denial
611/// uses -- verified live, the owner's sample fixture
612/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
613/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
614/// `authority` is NEVER derived from this text (see this module's own rule
615/// that authority comes only from a typed field) -- only `reason`/`help`
616/// are. Falls back to the full text as `reason` with no `help` when the
617/// shape does not match (a provider whose text does not carry this exact
618/// wording) -- the honest minimum: the tool truly did not run, so SOME
619/// reason is always reported, never fabricated detail beyond what arrived.
620fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
621    let trimmed = text.trim();
622    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
623        return (trimmed.to_owned(), None);
624    };
625    let tail = after_reason.trim_start();
626    let Some((reason, help)) = tail.split_once(". ") else {
627        // "Reason:" present but no ". " to split help off -- the whole
628        // tail is the reason, e.g. "Reason: Blocked by classifier." with
629        // no help text following on some providers/versions.
630        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
631        return (reason.to_owned(), None);
632    };
633    let help = help.trim();
634    if help.is_empty() {
635        (reason.trim().to_owned(), None)
636    } else {
637        (reason.trim().to_owned(), Some(help.to_owned()))
638    }
639}
640
641/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
642/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
643/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
644/// name is never threaded through to its completion). Reads the class off
645/// the guidance tail's own "add a <Class> permission rule" sentence when
646/// present (the shape the owner's sample fixture uses) -- text matching
647/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
648/// this module's own rule). Falls back to the same generic `"Tool"` class
649/// `ToolCompleted`'s own observation already uses when nothing names one.
650fn tool_class_from_denial_help(help: Option<&str>) -> String {
651    help.and_then(|text| text.split("add a ").nth(1))
652        .and_then(|tail| tail.split(' ').next())
653        .filter(|word| !word.is_empty())
654        .map(str::to_owned)
655        .unwrap_or_else(|| "Tool".to_owned())
656}
657
658/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
659/// message) names a quota/rate-limit exhaustion -- Codex's
660/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
661/// contains "limit", the word this build actually keys on; "quota"/"rate"
662/// are kept for whichever OTHER vendor's own code names one of those
663/// instead, never confirmed live, but the honest generalization of the
664/// one confirmed sample.
665fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
666    fn names_quota(s: &str) -> bool {
667        let lower = s.to_ascii_lowercase();
668        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
669    }
670    vendor_code.is_some_and(names_quota) || names_quota(message)
671}
672
673/// The `Blocked` chunk this build mints when a turn stopped via a
674/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
675/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
676/// vendor code (or message) names a quota/limit/rate exhaustion
677/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
678/// `reason_kind` = the vendor code). `None` for every other stop --
679/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
680/// `Other`, or an RPC error whose vendor code/message names none of the
681/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
682/// those, never inflated into a block this build cannot actually
683/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
684/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
685/// honest minimum names the fact that happened, never invented detail; a
686/// later slice that threads the turn's last agent text chunk through can
687/// replace it without changing this function's contract.
688fn provider_session_end_block(
689    stop_reason: &ProviderStopReason,
690) -> Option<(BlockAuthorityV1, String, String)> {
691    match stop_reason {
692        ProviderStopReason::Refusal => Some((
693            BlockAuthorityV1::ProviderRefusal,
694            "refusal".to_owned(),
695            "the provider ended the turn with stopReason: refusal".to_owned(),
696        )),
697        ProviderStopReason::ProviderError { message, vendor_code, .. }
698            if provider_error_is_quota(vendor_code.as_deref(), message) =>
699        {
700            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
701            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
702        }
703        _ => None,
704    }
705}
706
707/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
708/// `agent-stream-events-v1` channel -- see
709/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
710/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
711/// event it derives from, so a client joining the two sees one id and one
712/// ordering:
713///
714/// - `source_sequence` on the chunk is the `source_sequence` the provider
715///   event itself carries.
716/// - `InteractionPrompt::correlation_id` is minted by
717///   `correlation::interaction_correlation` with the
718///   `(instance_id, generation, provider_sequence)` triple -- never the
719///   provider's own `request_id`, whose opacity on the wire is pinned by a
720///   dedicated test.
721///
722/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
723/// (nothing else carries stream content), for a source that is not a live
724/// session transport, and for the `ProviderEvent` variants below with no
725/// declared chunk kind to carry them, or whose declared chunk kind this build
726/// has no source for yet:
727///
728/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
729///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
730///   yet, the same gap the plan names explicitly.
731/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
732///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
733///   as "what the agent is saying", so mapping the operator's words onto
734///   `Text` would misrepresent whose content it is. No dedicated
735///   user-message kind is declared, so this stays unproduced rather than
736///   mislabeled.
737///
738/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
739/// decision returns `None` (nothing for the operator to react to), a `Denied`
740/// decision returns `Some(Blocked { .. })`.
741///
742/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
743/// below (K2): each returns `None` unless
744/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
745/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
746/// RPC error naming a quota).
747fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
748    let ControlEventKind::ProviderEvent {
749        sequence: provider_sequence,
750        source,
751        source_sequence,
752        event: provider_event,
753    } = &event.event
754    else {
755        return None;
756    };
757    if !provider_family_is_live(source.family) {
758        return None;
759    }
760    let kind = match provider_event {
761        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
762            text: text.clone(),
763            is_delta: *is_delta,
764        },
765        ProviderEvent::Thinking { text } => {
766            AgentStreamChunkKindV1::Thinking { text: text.clone() }
767        }
768        ProviderEvent::InteractionRequested {
769            interaction_kind,
770            tool_name,
771            title,
772            prompt,
773            options,
774            ..
775        } => AgentStreamChunkKindV1::InteractionPrompt {
776            correlation_id: interaction_correlation(
777                event.instance_id,
778                event.generation,
779                *provider_sequence,
780            ),
781            interaction_kind: *interaction_kind,
782            tool_name: tool_name.clone(),
783            // `title`/`options` are carried straight through from
784            // `ProviderEvent::InteractionRequested`, as the agent itself
785            // gave them on `session/request_permission`'s `PermissionTool
786            // Call.title`/`options` (see `gate4agent-shell-native`'s
787            // `provider_event`, the only ACP-transport producer). A PTY- or
788            // hook-sourced interaction carries `title: None` and
789            // `options: []` from its own producer because that source has
790            // none to offer, never because this mapping dropped one.
791            title: title.clone(),
792            prompt: prompt.clone(),
793            options: options
794                .iter()
795                .map(|option| AgentStreamInteractionOptionV1 {
796                    option_id: option.option_id.clone(),
797                    name: option.name.clone(),
798                    kind: option.kind.clone(),
799                })
800                .collect(),
801        },
802        ProviderEvent::ConfigOptionsUpdated { options } => {
803            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
804        }
805        // `available` is the mode catalogue `AcpSession::available_modes()`
806        // read back at `session/new` and threaded through by
807        // `gate4agent-shell-native`'s `provider_event` (see that crate's
808        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
809        // agent's own honest "no modes announced", never "not read": ACP
810        // orders `session/new` before any `session/update`, and the
811        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
812        // exist before the same session object's catalogue is already
813        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
814        // own doc comment for the ordering argument in full).
815        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
816            current: Some(mode_id.clone()),
817            available: available
818                .iter()
819                .map(|mode| AgentStreamNamedIdV1 {
820                    id: mode.id.clone(),
821                    name: mode.name.clone(),
822                    description: mode.description.clone(),
823                })
824                .collect(),
825        },
826        // A denied host request is a block -- see this function's own doc
827        // comment for why `HostRequestObserved` is not in the exclusion list
828        // below.
829        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
830            let ProviderHostRequestDecision::Denied { by } = decision else {
831                return None;
832            };
833            let (tool_class, authority, reason_text) =
834                host_request_denied_block(method, *by, reason.as_deref());
835            AgentStreamChunkKindV1::Blocked {
836                correlation_id: None,
837                tool_class,
838                authority,
839                reason_kind: None,
840                reason: reason_text,
841                help: None,
842            }
843        }
844        // Same relationship as `HostRequestObserved` above, driven by ACP's
845        // own `_meta.nonExecutionKind` instead of the host's
846        // `HostRequestDecision`.
847        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
848            let (authority, reason_kind) =
849                provider_tool_block_authority(non_execution_kind.as_deref())?;
850            let (reason, help) = split_provider_denial_text(output);
851            let (reason, _) =
852                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
853            let help = help.map(|help| {
854                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
855            });
856            AgentStreamChunkKindV1::Blocked {
857                correlation_id: Some(tool_correlation(event, source, id)),
858                tool_class: tool_class_from_denial_help(help.as_deref()),
859                authority,
860                reason_kind: Some(reason_kind.to_owned()),
861                reason,
862                help,
863            }
864        }
865        // Same relationship, driven by `stopReason: "refusal"` or a
866        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
867        // instead of a tool-level fact.
868        ProviderEvent::SessionEnded { stop_reason, .. } => {
869            let (authority, reason_kind, reason) =
870                stop_reason.as_ref().and_then(provider_session_end_block)?;
871            let (reason, _) =
872                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
873            AgentStreamChunkKindV1::Blocked {
874                correlation_id: None,
875                tool_class: "Session".to_owned(),
876                authority,
877                reason_kind: Some(reason_kind),
878                reason,
879                help: None,
880            }
881        }
882        ProviderEvent::SessionStarted { .. }
883        | ProviderEvent::SessionIdentityObserved { .. }
884        | ProviderEvent::TurnStarted { .. }
885        | ProviderEvent::WorkingObserved
886        | ProviderEvent::ToolStarted { .. }
887        | ProviderEvent::TurnCompleted { .. }
888        | ProviderEvent::ContextWindowUsage { .. }
889        | ProviderEvent::TurnInterrupted
890        | ProviderEvent::Error { .. }
891        | ProviderEvent::Ready
892        | ProviderEvent::InteractionResolved { .. }
893        | ProviderEvent::SubagentStarted { .. }
894        | ProviderEvent::SubagentStopped { .. }
895        | ProviderEvent::RateLimited { .. }
896        | ProviderEvent::UnrecognizedNotification { .. }
897        | ProviderEvent::UserMessage { .. }
898        | ProviderEvent::Plan { .. }
899        | ProviderEvent::AvailableCommandsUpdated { .. }
900        | ProviderEvent::SessionInfoUpdated { .. }
901        | ProviderEvent::UsageUpdated { .. } => return None,
902    };
903    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
904}
905
906async fn run_native_session_catalog_operation<T, E, F>(
907    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
908    operation: &'static str,
909    execute: F,
910) -> Result<T, NodeFailure>
911where
912    T: Send + 'static,
913    E: std::fmt::Display + Send + 'static,
914    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
915{
916    let task = tokio::task::spawn_blocking(move || {
917        let mut catalog = match catalog.try_lock() {
918            Ok(catalog) => catalog,
919            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
920            Err(std::sync::TryLockError::WouldBlock) => {
921                return Err(failure(
922                    NodeFailureCode::BackendBusy,
923                    "native session catalog operation is already running",
924                ));
925            }
926        };
927        execute(&mut catalog).map_err(|error| {
928            failure(
929                NodeFailureCode::BackendOperationFailed,
930                &format!("{operation} failed: {error}"),
931            )
932        })
933    });
934    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
935        .await
936        .map_err(|_| {
937            failure(
938                NodeFailureCode::BackendBusy,
939                &format!("{operation} exceeded its bounded deadline"),
940            )
941        })?
942        .map_err(|_| {
943            failure(
944                NodeFailureCode::BackendOperationFailed,
945                &format!("{operation} task failed"),
946            )
947        })?
948}
949
950fn windows_path_text(path: &OpaqueHostPath) -> &str {
951    path.as_utf8()
952        .expect("Windows node only creates UTF-8 opaque host paths")
953}
954
955fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
956    path.as_utf8()
957        .map(str::to_owned)
958        .ok_or_else(|| failure(
959            NodeFailureCode::InvalidRequest,
960            "this node requires UTF-8 host paths",
961        ))
962}
963
964fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
965    GitWorktreeSnapshot {
966        path: opaque_windows_path(worktree.path),
967        head: worktree.head,
968        branch: worktree.branch,
969        is_bare: worktree.is_bare,
970        is_main: worktree.is_main,
971        locked: worktree.locked,
972        lock_reason: worktree.lock_reason,
973        prunable: worktree.prunable,
974        prunable_reason: worktree.prunable_reason,
975        workspace_id: worktree.workspace_id.take(),
976    }
977}
978
979#[derive(Clone, Debug, Eq, PartialEq)]
980pub struct WorkspaceConfig {
981    workspace_id: WorkspaceId,
982    canonical_root: String,
983    worktree_service_mode: WorktreeServiceMode,
984    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
985}
986
987impl WorkspaceConfig {
988    pub fn new(
989        workspace_id: WorkspaceId,
990        root: impl AsRef<Path>,
991    ) -> Result<Self, NodeServerError> {
992        let root = root.as_ref();
993        if !root.is_absolute() {
994            return Err(NodeServerError::InvalidWorkspaceRoot {
995                workspace_id,
996                path: root.to_string_lossy().into_owned(),
997                message: "path is not absolute".to_owned(),
998            });
999        }
1000        let canonical = std::fs::canonicalize(root).map_err(|error| {
1001            NodeServerError::InvalidWorkspaceRoot {
1002                workspace_id: workspace_id.clone(),
1003                path: root.to_string_lossy().into_owned(),
1004                message: error.to_string(),
1005            }
1006        })?;
1007        if !canonical.is_dir() {
1008            return Err(NodeServerError::InvalidWorkspaceRoot {
1009                workspace_id,
1010                path: root.to_string_lossy().into_owned(),
1011                message: "path is not a directory".to_owned(),
1012            });
1013        }
1014        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1015            NodeServerError::InvalidWorkspaceRoot {
1016                workspace_id: workspace_id.clone(),
1017                path: path.to_string_lossy().into_owned(),
1018                message: "canonical path is not valid Unicode".to_owned(),
1019            }
1020        })?;
1021        let canonical_root = platform::normalize_canonical_root(canonical_root);
1022        validate_workspace_root(&workspace_id, &canonical_root)?;
1023        Ok(Self {
1024            workspace_id,
1025            canonical_root,
1026            worktree_service_mode: WorktreeServiceMode::Manual,
1027            managed_worktree_profiles: BTreeMap::new(),
1028        })
1029    }
1030
1031    pub fn workspace_id(&self) -> &WorkspaceId {
1032        &self.workspace_id
1033    }
1034
1035    pub fn canonical_root(&self) -> &str {
1036        &self.canonical_root
1037    }
1038
1039    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1040        self.worktree_service_mode = mode;
1041        self
1042    }
1043
1044    pub fn with_managed_worktree_profile(
1045        mut self,
1046        profile: ManagedWorktreeProfile,
1047    ) -> Result<Self, NodeServerError> {
1048        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1049            NodeServerError::InvalidManagedWorktreeProfile {
1050                workspace_id: self.workspace_id.clone(),
1051                profile_id: profile.profile_id().clone(),
1052                message,
1053            }
1054        })?;
1055        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1056            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1057                workspace_id: self.workspace_id,
1058                profile_id: profile.profile_id().clone(),
1059            });
1060        }
1061        if self.managed_worktree_profiles.len()
1062            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1063        {
1064            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1065                workspace_id: self.workspace_id,
1066                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1067            });
1068        }
1069        self.managed_worktree_profiles
1070            .insert(profile.profile_id().clone(), profile);
1071        Ok(self)
1072    }
1073}
1074
1075#[derive(Clone)]
1076pub struct NodeServerConfig {
1077    pub endpoint: String,
1078    api_listen: Option<std::net::SocketAddr>,
1079    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1080    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1081    bridge_listen: Option<std::net::SocketAddr>,
1082    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1083    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1084    bridge_token: Option<String>,
1085    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1086    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1087    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1088    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1089    /// node purely a listener, which is what it has always been.
1090    call_home: Option<std::net::SocketAddr>,
1091    /// When set, call-home authenticates by signing the relay's challenge with this
1092    /// identity (enrolled node) and falls back to the token handshake against a relay
1093    /// that sends no challenge.
1094    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1095    pub node_id: NodeId,
1096    pub workspaces: Vec<WorkspaceConfig>,
1097    access_token: String,
1098    pub runtime: NativeRuntimeConfig,
1099    session_record_retention: SessionRecordRetentionConfig,
1100    state_path: Option<PathBuf>,
1101    spawn_profiles: SpawnProfileRegistry,
1102    session_environment: Option<NodeSessionEnvironmentConfig>,
1103    history: Option<NativeHistoryConfig>,
1104    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1105    /// Station network allowlist catalog (opaque ids). Empty default.
1106    /// Loaded at startup from `--network-allowlist-catalog` / env
1107    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1108    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1109    #[cfg(feature = "fixture")]
1110    fixture_raw_pty_runtime: bool,
1111}
1112
1113#[derive(Clone)]
1114struct NodeSessionEnvironmentConfig {
1115    root: PathBuf,
1116    resolver: Arc<dyn NodeSecretResolver>,
1117}
1118
1119impl NodeServerConfig {
1120    pub fn new(
1121        endpoint: impl Into<String>,
1122        access_token: impl Into<String>,
1123        node_id: NodeId,
1124        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1125    ) -> Result<Self, NodeServerError> {
1126        let endpoint = endpoint.into();
1127        let access_token = access_token.into();
1128        if !platform::validate_endpoint(&endpoint) {
1129            return Err(NodeServerError::InvalidEndpoint);
1130        }
1131        if access_token.is_empty() || access_token.len() > 4_096 {
1132            return Err(NodeServerError::InvalidAccessToken);
1133        }
1134        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1135        if workspaces.is_empty() {
1136            return Err(NodeServerError::NoWorkspaces);
1137        }
1138        let mut workspace_ids = BTreeMap::new();
1139        let mut workspace_roots = BTreeMap::new();
1140        for workspace in &workspaces {
1141            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1142                && workspace.managed_worktree_profiles.is_empty()
1143            {
1144                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1145                    workspace.workspace_id.clone(),
1146                ));
1147            }
1148            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1149                && !workspace.managed_worktree_profiles.is_empty()
1150            {
1151                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1152                    workspace.workspace_id.clone(),
1153                ));
1154            }
1155            if workspace_ids
1156                .insert(workspace.workspace_id.clone(), ())
1157                .is_some()
1158            {
1159                return Err(NodeServerError::DuplicateWorkspaceId(
1160                    workspace.workspace_id.clone(),
1161                ));
1162            }
1163            let root_key = platform::root_identity(&workspace.canonical_root);
1164            if let Some(existing) = workspace_roots.insert(
1165                root_key,
1166                workspace.workspace_id.clone(),
1167            ) {
1168                return Err(NodeServerError::DuplicateWorkspaceRoot {
1169                    first: existing,
1170                    second: workspace.workspace_id.clone(),
1171                    root: workspace.canonical_root.clone(),
1172                });
1173            }
1174        }
1175        Ok(Self {
1176            endpoint,
1177            api_listen: None,
1178            bridge_listen: None,
1179            bridge_token: None,
1180            bridge_underlay: None,
1181            call_home: None,
1182            call_home_identity: None,
1183            node_id,
1184            workspaces,
1185            access_token,
1186            // PTY output is polled by the runtime's effect worker; the upstream
1187            // default (20ms) added ~10-20ms to every keystroke echo.
1188            runtime: NativeRuntimeConfig {
1189                worker_poll_interval_ms: 4,
1190                ..NativeRuntimeConfig::default()
1191            },
1192            session_record_retention: SessionRecordRetentionConfig::default(),
1193            state_path: None,
1194            spawn_profiles: SpawnProfileRegistry::default(),
1195            session_environment: None,
1196            history: None,
1197            harness_mcp_helper: None,
1198            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1199            #[cfg(feature = "fixture")]
1200            fixture_raw_pty_runtime: false,
1201        })
1202    }
1203
1204    pub fn with_api_listen(
1205        mut self,
1206        api_listen: std::net::SocketAddr,
1207    ) -> Result<Self, NodeServerError> {
1208        if !api_listen.ip().is_loopback() {
1209            return Err(NodeServerError::InvalidApiListen(api_listen));
1210        }
1211        self.api_listen = Some(api_listen);
1212        Ok(self)
1213    }
1214
1215    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1216    /// as `with_api_listen`). Does not enable by default — libraries and the
1217    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1218    pub fn with_bridge_listen(
1219        mut self,
1220        bridge_listen: std::net::SocketAddr,
1221    ) -> Result<Self, NodeServerError> {
1222        if !bridge_listen.ip().is_loopback() {
1223            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1224        }
1225        self.bridge_listen = Some(bridge_listen);
1226        Ok(self)
1227    }
1228
1229    /// Optional bridge shared secret, distinct from the C2/node access token.
1230    /// Never log the value. Empty / oversize refused.
1231    pub fn with_bridge_token(
1232        mut self,
1233        bridge_token: impl Into<String>,
1234    ) -> Result<Self, NodeServerError> {
1235        let bridge_token = bridge_token.into();
1236        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1237            return Err(NodeServerError::InvalidBridgeToken);
1238        }
1239        self.bridge_token = Some(bridge_token);
1240        Ok(self)
1241    }
1242
1243    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1244    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1245    /// already configured. Transport key is 32 raw bytes (never logged).
1246    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1247    pub fn with_bridge_underlay(
1248        mut self,
1249        bind: std::net::SocketAddr,
1250        transport_key: [u8; 32],
1251        underlay_token: impl Into<String>,
1252    ) -> Result<Self, NodeServerError> {
1253        if self.bridge_listen.is_none() {
1254            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1255        }
1256        if self.bridge_token.is_none() {
1257            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1258        }
1259        let underlay_token = underlay_token.into();
1260        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1261            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1262        }
1263        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1264        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1265            bind,
1266            transport_key,
1267            underlay_token,
1268        });
1269        Ok(self)
1270    }
1271
1272    /// Also dial `relay` and serve this wire over the connection, in
1273    /// addition to accepting on the local endpoint.
1274    ///
1275    /// Who opens the socket and who is the protocol's server are separate
1276    /// questions, and this is the node's half of separating them. The node
1277    /// stays the server either way -- it answers `NodeRequest`s and emits
1278    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1279    /// that changes is that a node with no address anyone can reach makes
1280    /// the connection itself, and names itself once it arrives (see
1281    /// `gate4agent_node_wire::write_call_home_announce`).
1282    ///
1283    /// Loopback only, and that is not an oversight to be flagged and
1284    /// worked around. This wire carries terminal contents, keystrokes and
1285    /// file bytes as plaintext JSON; its mutual challenge-response
1286    /// authenticates both ends and encrypts nothing. Every listener in
1287    /// this stack is loopback-guarded for that reason, and a dialler must
1288    /// hold the same line -- a node calling a public relay would put the
1289    /// whole control plane on the wire in the clear. Reaching a relay on
1290    /// another host is a transport question, and it stays unanswered here
1291    /// on purpose: this change is about direction, not distance.
1292    pub fn with_call_home(
1293        mut self,
1294        relay: std::net::SocketAddr,
1295    ) -> Result<Self, NodeServerError> {
1296        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1297        // and port 0 refused — plaintext wire must not dial every interface.
1298        if relay.port() == 0 || relay.ip().is_unspecified() {
1299            return Err(NodeServerError::InvalidCallHome(relay));
1300        }
1301        self.call_home = Some(relay);
1302        Ok(self)
1303    }
1304
1305    /// Authenticate call-home with a node-held identity instead of a standing token.
1306    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1307        self.call_home_identity = Some(identity);
1308        self
1309    }
1310
1311    pub fn with_state_path(
1312        mut self,
1313        state_path: impl Into<PathBuf>,
1314    ) -> Result<Self, NodeServerError> {
1315        let state_path = state_path.into();
1316        if !state_path.is_absolute() || state_path.file_name().is_none() {
1317            return Err(NodeServerError::InvalidStatePath(
1318                state_path.to_string_lossy().into_owned(),
1319            ));
1320        }
1321        self.state_path = Some(state_path);
1322        Ok(self)
1323    }
1324
1325    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1326        self.spawn_profiles = spawn_profiles;
1327        self
1328    }
1329
1330    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1331    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1332    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1333    /// when this is never called -- a node never deletes a durable record
1334    /// until an operator opts in with a real value.
1335    pub fn with_session_record_retention(
1336        mut self,
1337        retention: SessionRecordRetentionConfig,
1338    ) -> Self {
1339        self.session_record_retention = retention;
1340        self
1341    }
1342
1343    pub fn with_session_environment_materialization(
1344        mut self,
1345        root: impl Into<PathBuf>,
1346        resolver: Arc<dyn NodeSecretResolver>,
1347    ) -> Result<Self, NodeServerError> {
1348        let root = root.into();
1349        if !root.is_absolute() {
1350            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1351        }
1352        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1353        Ok(self)
1354    }
1355
1356    /// Enables bounded provider-history discovery and loading from explicit
1357    /// host-owned roots. Ambient vendor homes are never inferred here.
1358    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1359        self.history = Some(history);
1360        self
1361    }
1362
1363    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1364    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1365    /// Both platforms review the helper the same way (absolute regular file +
1366    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1367    /// gate after Unix listener/peer-cred support landed.
1368    pub fn with_harness_mcp_helper(
1369        mut self,
1370        helper_program: impl Into<PathBuf>,
1371    ) -> Result<Self, NodeServerError> {
1372        self.harness_mcp_helper = Some(
1373            ReviewedHarnessMcpProgram::review(helper_program.into())
1374                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1375        );
1376        Ok(self)
1377    }
1378
1379    /// Install an in-memory station network allowlist catalog (opaque ids +
1380    /// optional node-local permits / provider-native mapping). Empty remains
1381    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1382    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1383    pub fn with_network_allowlist_catalog(
1384        mut self,
1385        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1386    ) -> Result<Self, NodeServerError> {
1387        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1388            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1389                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1390            });
1391        }
1392        self.network_allowlist_catalog = catalog;
1393        Ok(self)
1394    }
1395
1396    /// Load station network allowlist catalog from an absolute regular file
1397    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1398    /// that want env fallback use `resolve_network_allowlist_catalog`.
1399    pub fn with_network_allowlist_catalog_file(
1400        self,
1401        path: impl AsRef<std::path::Path>,
1402    ) -> Result<Self, NodeServerError> {
1403        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1404            .map_err(NodeServerError::from)?;
1405        self.with_network_allowlist_catalog(catalog)
1406    }
1407}
1408
1409pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1410    platform::default_state_path(node_id.as_str())
1411        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1412}
1413
1414pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1415    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1416}
1417
1418fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1419    let parent = state_path.parent()?;
1420    let mut name = state_path.file_name()?.to_os_string();
1421    name.push(".delivery-store");
1422    Some(parent.join(name))
1423}
1424
1425fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1426    let parent = state_path.parent()?;
1427    let mut name = state_path.file_name()?.to_os_string();
1428    name.push(".context-pack-store");
1429    Some(parent.join(name))
1430}
1431
1432pub struct NodeServer {
1433    config: NodeServerConfig,
1434    runtime: NativeRuntime,
1435    shared: Arc<NodeShared>,
1436    events: EventSubscription,
1437    state_path_lock: Option<session_registry::StatePathLock>,
1438}
1439
1440#[cfg(feature = "fixture")]
1441#[derive(Clone, Default)]
1442pub struct SpawnManagedWorktreeV2FailureProbe {
1443    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1444}
1445
1446#[cfg(feature = "fixture")]
1447impl SpawnManagedWorktreeV2FailureProbe {
1448    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1449        *self
1450            .latest_code
1451            .lock()
1452            .unwrap_or_else(|poisoned| poisoned.into_inner())
1453    }
1454
1455    fn record(&self, code: Option<NodeFailureCode>) {
1456        *self
1457            .latest_code
1458            .lock()
1459            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1460    }
1461}
1462
1463#[cfg(all(feature = "fixture", windows))]
1464struct IsolatedCodexHomeFixtureEnvironment;
1465
1466#[cfg(all(feature = "fixture", windows))]
1467impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1468    for IsolatedCodexHomeFixtureEnvironment
1469{
1470    fn resolve_child_environment(
1471        &self,
1472    ) -> Result<
1473        Vec<gate4agent_catalog::EnvMutation>,
1474        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1475    > {
1476        Ok(vec![gate4agent_catalog::EnvMutation {
1477            key: OsString::from("USERPROFILE"),
1478            value: None,
1479        }])
1480    }
1481}
1482
1483impl NodeServer {
1484    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1485    /// must present.
1486    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1487    #[cfg(unix)]
1488    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1489        self.shared.signer_proxy.set(proxy).is_ok()
1490    }
1491
1492    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1493        self.shared.m4a_accounts.as_ref()?;
1494        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1495    }
1496
1497    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1498        let catalog = active_registry()?;
1499        Self::new_with_registry(config, catalog)
1500    }
1501
1502    #[cfg(feature = "fixture")]
1503    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1504        let mut spec = gate4agent_testkit::interactive_agent_spec();
1505        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1506        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1507        Self::new_fixture_with_spec(config, spec)
1508    }
1509
1510    #[cfg(feature = "fixture")]
1511    pub fn new_clean_exit_fixture(
1512        config: NodeServerConfig,
1513        fixture_root: PathBuf,
1514        started_marker: PathBuf,
1515        release_signal: PathBuf,
1516    ) -> Result<Self, NodeServerError> {
1517        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1518            &fixture_root,
1519            &started_marker,
1520            &release_signal,
1521        )
1522        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        spec.id = AgentId::new("claude")
1524            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1525        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1526        Self::new_fixture_with_spec(config, spec)
1527    }
1528
1529    /// A `claude` source whose session establishes a verified provider
1530    /// identity via an early `SessionStart` Hook post (so it is eligible for
1531    /// auto-export-at-exit and its native history is independently
1532    /// loadable), waits for `release_signal`, then exits cleanly — paired
1533    /// on the same Node with a plain `codex` target that never validates
1534    /// anything about what it receives. Built for A2 durable ContextPack
1535    /// E2E coverage: the test itself inspects materialized files and
1536    /// managed-session-record fields directly, rather than having the
1537    /// target script self-validate.
1538    #[cfg(all(feature = "fixture", windows))]
1539    pub fn new_durable_context_pack_clean_exit_fixture(
1540        config: NodeServerConfig,
1541        fixture_root: PathBuf,
1542        started_marker: PathBuf,
1543        release_signal: PathBuf,
1544        session_id: &str,
1545    ) -> Result<Self, NodeServerError> {
1546        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1547            &fixture_root,
1548            &started_marker,
1549            &release_signal,
1550            session_id,
1551        )
1552        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let source_id = AgentId::new("claude")
1554            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1555        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1556            NodeServerError::Registry(
1557                "durable context pack clean-exit fixture source adapter is unavailable"
1558                    .to_owned(),
1559            )
1560        })?;
1561        source_spec.capabilities.adapters.history =
1562            source_provider.capabilities.adapters.history.clone();
1563        source_spec.id = source_id;
1564        source_spec.display_name =
1565            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1566        // Both specs launch via `powershell.exe`; the registry's ambiguity
1567        // check keys on `detection.command` (+ aliases), not `launch.program`
1568        // -- give each a distinct detection identity, same precedent as
1569        // `context_pack_fixture_catalog`'s per-provider
1570        // `gate4agent-{provider}-context-fixture.cmd` commands.
1571        source_spec.detection.command =
1572            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1573        source_spec.detection.aliases.clear();
1574
1575        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1576        target_spec.id = AgentId::new("codex")
1577            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1578        target_spec.display_name =
1579            "Controlled Codex durable context-pack target fixture".to_owned();
1580        target_spec.detection.command =
1581            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1582        target_spec.detection.aliases.clear();
1583
1584        let catalog = AgentRegistry::new([source_spec, target_spec])
1585            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586        let mut server = Self::new_with_registry(config, catalog)?;
1587        Arc::get_mut(&mut server.shared)
1588            .expect("fixture Node shared state must remain uniquely owned before run")
1589            .fixture_semantic_hook_policy = true;
1590        Ok(server)
1591    }
1592
1593    #[cfg(feature = "fixture")]
1594    pub fn new_monitoring_hook_fixture(
1595        config: NodeServerConfig,
1596    ) -> Result<Self, NodeServerError> {
1597        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1598        spec.id = AgentId::new("claude")
1599            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600        let mut server = Self::new_fixture_with_spec(config, spec)?;
1601        Arc::get_mut(&mut server.shared)
1602            .expect("fixture Node shared state must remain uniquely owned before run")
1603            .fixture_semantic_hook_policy = true;
1604        Ok(server)
1605    }
1606
1607    #[cfg(all(feature = "fixture", windows))]
1608    pub fn new_provider_bundle_argv_fixture(
1609        config: NodeServerConfig,
1610        agent_id: AgentId,
1611        proof_path: PathBuf,
1612    ) -> Result<Self, NodeServerError> {
1613        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1614        Self::new_fixture_with_spec(config, spec)
1615    }
1616
1617    #[cfg(all(feature = "fixture", windows))]
1618    pub fn new_provider_bundle_argv_hold_fixture(
1619        config: NodeServerConfig,
1620        agent_id: AgentId,
1621        proof_path: PathBuf,
1622        release_signal: PathBuf,
1623    ) -> Result<Self, NodeServerError> {
1624        let spec = Self::provider_bundle_argv_fixture_spec(
1625            agent_id,
1626            proof_path,
1627            Some(release_signal),
1628        )?;
1629        Self::new_fixture_with_spec(config, spec)
1630    }
1631
1632    #[cfg(all(feature = "fixture", windows))]
1633    fn provider_bundle_argv_fixture_spec(
1634        agent_id: AgentId,
1635        proof_path: PathBuf,
1636        release_signal: Option<PathBuf>,
1637    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1638        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1639            return Err(NodeServerError::Registry(
1640                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1641            ));
1642        }
1643        let mut spec = gate4agent_testkit::interactive_agent_spec();
1644        spec.id = agent_id.clone();
1645        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1646        let provider = builtin_registry()
1647            .get(&agent_id)
1648            .ok_or_else(|| {
1649                NodeServerError::Registry(
1650                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1651                )
1652            })?;
1653        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1654        if !proof_path.is_absolute()
1655            || !proof_path
1656                .parent()
1657                .is_some_and(|parent| parent.is_dir())
1658        {
1659            return Err(NodeServerError::Registry(
1660                "provider bundle argv proof path requires an existing absolute parent"
1661                    .to_owned(),
1662            ));
1663        }
1664        if let Some(release_signal) = release_signal.as_ref() {
1665            if !release_signal.is_absolute()
1666                || !release_signal
1667                    .parent()
1668                    .is_some_and(|parent| parent.is_dir())
1669            {
1670                return Err(NodeServerError::Registry(
1671                    "provider bundle argv release path requires an existing absolute parent"
1672                        .to_owned(),
1673                ));
1674            }
1675            if release_signal.exists() {
1676                return Err(NodeServerError::Registry(
1677                    "provider bundle argv release path must not exist".to_owned(),
1678                ));
1679            }
1680            let proof_parent = std::fs::canonicalize(
1681                proof_path
1682                    .parent()
1683                    .expect("validated provider bundle proof parent"),
1684            )
1685            .map_err(|_| {
1686                NodeServerError::Registry(
1687                    "provider bundle argv proof parent is unavailable".to_owned(),
1688                )
1689            })?;
1690            let release_parent = std::fs::canonicalize(
1691                release_signal
1692                    .parent()
1693                    .expect("validated provider bundle release parent"),
1694            )
1695            .map_err(|_| {
1696                NodeServerError::Registry(
1697                    "provider bundle argv release parent is unavailable".to_owned(),
1698                )
1699            })?;
1700            if proof_parent != release_parent {
1701                return Err(NodeServerError::Registry(
1702                    "provider bundle argv release path must share the proof parent".to_owned(),
1703                ));
1704            }
1705        }
1706        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1707            NodeServerError::Registry(
1708                "provider bundle argv proof path is not valid Unicode".to_owned(),
1709            )
1710        })?;
1711        let release_signal = release_signal.map(|release_signal| {
1712            release_signal.into_os_string().into_string().map_err(|_| {
1713                NodeServerError::Registry(
1714                    "provider bundle argv release path is not valid Unicode".to_owned(),
1715                )
1716            })
1717        }).transpose()?;
1718        let script = spec
1719            .launch
1720            .fixed_args
1721            .pop()
1722            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1723        let bundle_validation = if agent_id.as_str() == "codex" {
1724            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1725        } else if agent_id.as_str() == "claude" {
1726            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1727        } else {
1728            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1729        };
1730        if let Some(release_signal) = release_signal {
1731            spec.launch.fixed_args.push(format!(
1732                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1733            ));
1734            spec.launch.fixed_args.push(proof_path);
1735            spec.launch.fixed_args.push(release_signal);
1736        } else {
1737            spec.launch.fixed_args.push(format!(
1738                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1739            ));
1740            spec.launch.fixed_args.push(proof_path);
1741        }
1742        Ok(spec)
1743    }
1744
1745    #[cfg(all(feature = "fixture", windows))]
1746    pub fn new_context_pack_fixture(
1747        config: NodeServerConfig,
1748        proof_path: PathBuf,
1749    ) -> Result<Self, NodeServerError> {
1750        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1751        Self::new_with_registry(config, catalog)
1752    }
1753
1754    #[cfg(all(feature = "fixture", windows))]
1755    pub fn new_monitoring_context_pack_fixture(
1756        config: NodeServerConfig,
1757        proof_path: PathBuf,
1758    ) -> Result<Self, NodeServerError> {
1759        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1760        let mut server = Self::new_with_registry(config, catalog)?;
1761        Arc::get_mut(&mut server.shared)
1762            .expect("fixture Node shared state must remain uniquely owned before run")
1763            .fixture_semantic_hook_policy = true;
1764        Ok(server)
1765    }
1766
1767    #[cfg(all(feature = "fixture", windows))]
1768    pub fn install_isolated_codex_home_environment_fixture(
1769        &mut self,
1770        profile_id: SpawnEnvironmentProfileId,
1771        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1772    ) -> Result<(), NodeServerError> {
1773        let provider = AgentId::new("codex")
1774            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1775        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1776            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1777                "{}-pty",
1778                profile_id.as_str(),
1779            ))
1780            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1781            provider.clone(),
1782            TransportKind::Pty,
1783            vec![OsString::from("USERPROFILE")],
1784            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1785        )
1786        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787        let materialization = NodeSessionMaterializationProfile::new(
1788            Vec::new(),
1789            vec![crate::session_environment::NodeSessionPathBinding::new(
1790                "CODEX_HOME",
1791                crate::session_environment::NodeSessionPathClass::ProviderHome,
1792            )
1793            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1794            Vec::new(),
1795        )
1796        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1797        self.install_environment_profile(
1798            NodeEnvironmentProfile::new_with_materialization(
1799                profile_id,
1800                profile_revision,
1801                provider,
1802                [native_profile],
1803                Some(materialization),
1804            )
1805            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1806        )
1807    }
1808
1809    #[cfg(all(feature = "fixture", windows))]
1810    pub fn new_context_only_proof_fixture(
1811        config: NodeServerConfig,
1812        target_provider: AgentId,
1813        proof_path: PathBuf,
1814    ) -> Result<Self, NodeServerError> {
1815        if target_provider.as_str() != "kimi" {
1816            return Err(NodeServerError::Registry(
1817                "context-only proof fixture requires Kimi".to_owned(),
1818            ));
1819        }
1820        let catalog = Self::context_pack_fixture_catalog(
1821            proof_path,
1822            Some(&target_provider),
1823            false,
1824        )?;
1825        Self::new_with_registry(config, catalog)
1826    }
1827
1828    #[cfg(all(feature = "fixture", windows))]
1829    fn context_pack_fixture_catalog(
1830        proof_path: PathBuf,
1831        context_only_target: Option<&AgentId>,
1832        monitoring_claude_source: bool,
1833    ) -> Result<AgentRegistry, NodeServerError> {
1834        if !proof_path.is_absolute()
1835            || !proof_path
1836                .parent()
1837                .is_some_and(|parent| parent.is_dir())
1838        {
1839            return Err(NodeServerError::Registry(
1840                "context pack proof path requires an existing absolute parent".to_owned(),
1841            ));
1842        }
1843        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1844            NodeServerError::Registry(
1845                "context pack proof path is not valid Unicode".to_owned(),
1846            )
1847        })?;
1848        let providers = ["claude", "codex", "grok", "kimi"];
1849        let builtins = builtin_registry();
1850        let mut specs = Vec::with_capacity(providers.len());
1851        for provider_id in providers {
1852            let agent_id = AgentId::new(provider_id)
1853                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1854            let provider = builtins.get(&agent_id).ok_or_else(|| {
1855                NodeServerError::Registry(
1856                    "context pack fixture provider adapter is unavailable".to_owned(),
1857                )
1858            })?;
1859            let monitored_provider =
1860                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1861            let mut spec = if monitored_provider {
1862                gate4agent_testkit::monitoring_hook_agent_spec()
1863            } else {
1864                gate4agent_testkit::interactive_agent_spec()
1865            };
1866            spec.id = agent_id.clone();
1867            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1868            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1869            spec.detection.aliases.clear();
1870            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1871            if provider_id == "codex" {
1872                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1873                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1874                })?;
1875                let validation = r#"
1876$bundleArgs = @($bundleArgs)
1877if ($bundleArgs.Count -ne 0) { exit 91 }
1878$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1879$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1880$cwd = (Get-Location).ProviderPath
1881if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1882$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1883if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1884$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1885if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1886$sha = [Security.Cryptography.SHA256]::Create()
1887try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1888if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1889if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1890$contextPath = Join-Path $contextRoot 'context-pack.json'
1891if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1892$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1893if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1894$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1896$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1897$separator = [IO.Path]::DirectorySeparatorChar
1898if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1899if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1900$rawContext = [IO.File]::ReadAllText($contextPath)
1901if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1902try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1903$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1904$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1905foreach ($property in @($document.PSObject.Properties.Name)) {
1906    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1907}
1908foreach ($property in $requiredTopLevel) {
1909    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1910}
1911if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1912if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1913$messages = @($document.retained_messages)
1914if ($messages.Count -eq 0) { exit 107 }
1915foreach ($message in $messages) {
1916    $messageProperties = @($message.PSObject.Properties.Name)
1917    foreach ($property in $messageProperties) {
1918        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1919    }
1920    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1921}
1922$sha = [Security.Cryptography.SHA256]::Create()
1923try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1924[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1925$rawProof = [IO.File]::ReadAllText($proofPath)
1926if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1927[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1928"#;
1929                spec.launch.fixed_args.push(format!(
1930                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1931                ));
1932                spec.launch.fixed_args.push(proof_path.clone());
1933            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1934                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1935                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1936                })?;
1937                let validation = r#"
1938$bundleArgs = @($bundleArgs)
1939if ($bundleArgs.Count -ne 0) { exit 91 }
1940$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1941$cwd = (Get-Location).ProviderPath
1942if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1943$contextPath = Join-Path $contextRoot 'context-pack.json'
1944if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1945$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1946if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1947$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1948$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1949$separator = [IO.Path]::DirectorySeparatorChar
1950if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1951try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1952if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1953if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1954$messages = @($document.retained_messages)
1955if ($messages.Count -eq 0) { exit 99 }
1956$hasUser = $false
1957$hasAssistant = $false
1958foreach ($message in $messages) {
1959    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1960    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1961    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1962    else { exit 101 }
1963}
1964if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1965$sha = [Security.Cryptography.SHA256]::Create()
1966try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1967[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1968[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1969"#;
1970                spec.launch.fixed_args.push(format!(
1971                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1972                ));
1973                spec.launch.fixed_args.push(proof_path.clone());
1974            }
1975            specs.push(spec);
1976        }
1977        let catalog = AgentRegistry::new(specs)
1978            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1979        Ok(catalog)
1980    }
1981
1982    #[cfg(feature = "fixture")]
1983    pub fn new_exact_launcher_fixture(
1984        config: NodeServerConfig,
1985        agent_id: AgentId,
1986        launcher: String,
1987    ) -> Result<Self, NodeServerError> {
1988        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1989            return Err(NodeServerError::Registry(
1990                "exact launcher fixture requires a supported provider".to_owned(),
1991            ));
1992        }
1993        if launcher.contains('\0') {
1994            return Err(NodeServerError::Registry(
1995                "exact launcher fixture path contains NUL".to_owned(),
1996            ));
1997        }
1998        let launcher_path = Path::new(&launcher);
1999        if !launcher_path.is_absolute() {
2000            return Err(NodeServerError::Registry(
2001                "exact launcher fixture path must be absolute".to_owned(),
2002            ));
2003        }
2004        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2005            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2006        })?;
2007        if !metadata.is_file() {
2008            return Err(NodeServerError::Registry(
2009                "exact launcher fixture path must be a regular file".to_owned(),
2010            ));
2011        }
2012        let mut spec = builtin_registry()
2013            .get(&agent_id)
2014            .ok_or_else(|| {
2015                NodeServerError::Registry(
2016                    "exact launcher fixture provider is unavailable".to_owned(),
2017                )
2018            })?
2019            .clone();
2020        spec.launch.program = launcher;
2021        Self::new_fixture_with_spec(config, spec)
2022    }
2023
2024    /// Shared H3B validation and spec construction for
2025    /// `new_harness_mcp_proxy_fixture` and
2026    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2027    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2028    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2029    /// the named builtin catalog entry -- detection command and expected
2030    /// process matcher rebased on the launcher's own basename, exactly the
2031    /// same shape `new_harness_mcp_proxy_fixture` always built.
2032    #[cfg(feature = "fixture")]
2033    fn h3b_provider_spec_with_program_and_args(
2034        agent_id: AgentId,
2035        provider_program: PathBuf,
2036        fixed_args: Vec<OsString>,
2037    ) -> Result<AgentSpec, NodeServerError> {
2038        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2039            return Err(NodeServerError::Registry(
2040                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2041            ));
2042        }
2043        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2044            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2045        })?;
2046        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2047            return Err(NodeServerError::Registry(
2048                "H3B fixture provider must be an exact regular file".to_owned(),
2049            ));
2050        }
2051        let program = provider_program.into_os_string().into_string().map_err(|_| {
2052            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2053        })?;
2054        let fixed_args = fixed_args.into_iter().map(|argument| {
2055            argument.into_string().map_err(|_| NodeServerError::Registry(
2056                "H3B fixture argument is not Unicode".to_owned(),
2057            ))
2058        }).collect::<Result<Vec<_>, _>>()?;
2059        let argument_bytes = fixed_args.iter()
2060            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2061        if program.contains('\0')
2062            || fixed_args.iter().any(|argument| argument.contains('\0'))
2063            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2064        {
2065            return Err(NodeServerError::Registry(
2066                "H3B fixture provider or arguments are invalid".to_owned(),
2067            ));
2068        }
2069        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2070            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2071        })?.clone();
2072        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2073            .ok_or_else(|| NodeServerError::Registry(
2074                "H3B fixture provider basename is unavailable".to_owned(),
2075            ))?.to_owned();
2076        spec.detection.command = process_name.clone();
2077        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2078            name: process_name,
2079        }];
2080        spec.launch.program = program;
2081        spec.launch.fixed_args = fixed_args;
2082        Ok(spec)
2083    }
2084
2085    #[cfg(feature = "fixture")]
2086    pub fn new_harness_mcp_proxy_fixture(
2087        config: NodeServerConfig,
2088        provider_program: PathBuf,
2089        fixed_args: Vec<OsString>,
2090        harness_mcp_program: PathBuf,
2091    ) -> Result<Self, NodeServerError> {
2092        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2093        let agent_id = AgentId::new("codex")
2094            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2095        let mut spec =
2096            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2097        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2098        config.fixture_raw_pty_runtime = true;
2099        Self::new_fixture_with_spec(config, spec)
2100    }
2101
2102    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2103    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2104    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2105    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2106    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2107    /// fixture_raw_pty_runtime = true`, which tears out the live
2108    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2109    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2110    /// hardcode `acp_transport = false` for every provider, refusing
2111    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2112    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2113    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2114    /// is built from the (cloned, launcher-replaced) catalog spec, and
2115    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2116    /// unmodified catalog fact: every builtin provider this accepts
2117    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2118    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2119    /// invented here -- this constructor only removes the fixture-only
2120    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2121    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2122    /// for every ACP session, fixture or not) is what actually grants the
2123    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2124    /// this constructor never calls it directly, and does not need to.
2125    #[cfg(feature = "fixture")]
2126    pub fn new_harness_mcp_acp_launcher_fixture(
2127        config: NodeServerConfig,
2128        helper_program: PathBuf,
2129        provider: AgentId,
2130        program: PathBuf,
2131        args: Vec<OsString>,
2132    ) -> Result<Self, NodeServerError> {
2133        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2134            return Err(NodeServerError::Registry(
2135                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2136            ));
2137        }
2138        let config = config.with_harness_mcp_helper(helper_program)?;
2139        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2140        let launch = spec.launch.clone();
2141        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2142            NodeServerError::Registry(
2143                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2144                    .to_owned(),
2145            )
2146        })?;
2147        // Routes the ACP spawn itself through the exact replaced launcher
2148        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2149        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2150        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2151        // without this the ACP transport ignores `spec.launch` entirely and
2152        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2153        // codex-acp@1.10.0`), which this fixture must never reach.
2154        acp.launch_override = Some(launch);
2155        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2156        Self::new_fixture_with_spec(config, spec)
2157    }
2158
2159    #[cfg(feature = "fixture")]
2160    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2161        let mut spec = gate4agent_testkit::interactive_agent_spec();
2162        let claude_id = AgentId::new("claude")
2163            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2164        let claude = builtin_registry()
2165            .get(&claude_id)
2166            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2167        spec.id = claude_id;
2168        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2169        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2170            name: "claude".to_owned(),
2171        }];
2172        spec.capabilities.transports.pty_adapter = claude
2173            .capabilities
2174            .transports
2175            .pty_adapter
2176            .clone();
2177        spec.capabilities.adapters.resume = claude
2178            .capabilities
2179            .adapters
2180            .resume
2181            .clone();
2182        let script = spec
2183            .launch
2184            .fixed_args
2185            .pop()
2186            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2187        spec.launch.fixed_args.push(format!(
2188            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2189        ));
2190        Self::new_fixture_with_spec(config, spec)
2191    }
2192
2193    #[cfg(feature = "fixture")]
2194    pub fn new_cmd_cwd_fixture(
2195        config: NodeServerConfig,
2196        command: String,
2197    ) -> Result<Self, NodeServerError> {
2198        let mut spec = gate4agent_testkit::interactive_agent_spec();
2199        spec.id = AgentId::new("claude")
2200            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2201        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2202        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2203        spec.launch.program = command;
2204        spec.launch.fixed_args.clear();
2205        Self::new_fixture_with_spec(config, spec)
2206    }
2207
2208    #[cfg(feature = "fixture")]
2209    fn new_fixture_with_spec(
2210        config: NodeServerConfig,
2211        spec: gate4agent_types::AgentSpec,
2212    ) -> Result<Self, NodeServerError> {
2213        let catalog = AgentRegistry::new([spec])
2214            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215        Self::new_with_registry(config, catalog)
2216    }
2217
2218    fn new_with_registry(
2219        config: NodeServerConfig,
2220        catalog: AgentRegistry,
2221    ) -> Result<Self, NodeServerError> {
2222        for profile in config.spawn_profiles.iter() {
2223            if catalog.get(&profile.provider).is_none() {
2224                return Err(NodeServerError::Registry(
2225                    "spawn profile references an unavailable provider".to_owned(),
2226                ));
2227            }
2228        }
2229        let input_settle_timeout_ms = catalog
2230            .iter()
2231            .map(|spec| spec.readiness.timeout_ms)
2232            .max()
2233            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2234            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2235            .max(MUTATION_SETTLE_TIMEOUT_MS);
2236        let (provider_contracts, provider_adapter_contracts) =
2237            provider_contract_manifest(&catalog)?;
2238        let compatibility_support = node_compatibility_support_for_manifest(
2239            &provider_contracts,
2240            &provider_adapter_contracts,
2241        )?;
2242        validate_node_negotiated_handshake_capacity(&compatibility_support)
2243            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2244        let enabled_providers = provider_contracts
2245            .iter()
2246            .map(|contract| contract.provider.clone())
2247            .collect();
2248        #[cfg(feature = "fixture")]
2249        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2250        #[cfg(not(feature = "fixture"))]
2251        let fixture_raw_pty_runtime = false;
2252        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2253            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2254                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2255            })).expect("fixture provider catalog remains bounded");
2256            (None, statuses)
2257        } else {
2258            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2259            let statuses = monitor.collect();
2260            (Some(monitor), statuses)
2261        };
2262        let state_path_lock =
2263            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2264                .map_err(|error| {
2265                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2266                })?;
2267        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2268            Some(state_path) => {
2269                let delivery_root = delivery_store_root_for_state_path(state_path)
2270                    .ok_or_else(|| {
2271                        NodeServerError::DeliveryStore(
2272                            "delivery store root could not be derived from state path".to_owned(),
2273                        )
2274                    })?;
2275                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2276                    let detail = match &error {
2277                        DeliveryStoreError::Storage(io_error) => {
2278                            format!("{error}: {io_error}")
2279                        }
2280                        _ => error.to_string(),
2281                    };
2282                    NodeServerError::DeliveryStore(detail)
2283                })?;
2284                (Some(store), bundles)
2285            }
2286            None => (None, Vec::new()),
2287        };
2288        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2289            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2290        })?;
2291        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2292            Some(state_path) => {
2293                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2294                    .ok_or(NodeServerError::ContextPackStore)?;
2295                let (store, packs) = ContextPackStore::open(context_pack_root)
2296                    .map_err(|_| NodeServerError::ContextPackStore)?;
2297                (Some(store), packs)
2298            }
2299            None => (None, Vec::new()),
2300        };
2301        let mut durable_context_catalog = ContextPackCatalog::default();
2302        for pack in durable_context_packs {
2303            durable_context_catalog
2304                .insert(pack)
2305                .map_err(|_| NodeServerError::ContextPackStore)?;
2306        }
2307        let session_environment_materializer = config
2308            .session_environment
2309            .as_ref()
2310            .map(|environment| {
2311                SessionEnvironmentMaterializer::new(
2312                    environment.root.clone(),
2313                    Arc::clone(&environment.resolver),
2314                )
2315                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2316            })
2317            .transpose()?;
2318        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2319        // (the provider version probe above already used the original catalog).
2320        #[cfg(unix)]
2321        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2322        let native_session_catalog = config.history.clone().map(|history| {
2323            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2324        });
2325        let (handle, runtime) = match config.history.clone() {
2326            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2327            None => NativeRuntime::new(catalog, config.runtime),
2328        };
2329        let native_launch_profile_control = runtime.native_launch_profile_control();
2330        let harness_mcp_registry = config
2331            .harness_mcp_helper
2332            .clone()
2333            .map(HarnessMcpProxyRegistry::new);
2334        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2335        let incarnation_id = random_incarnation_id()
2336            .map_err(NodeServerError::IncarnationIdentity)?;
2337        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2338            .map_err(durable_state_load_error)?;
2339        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2340        let managed_worktree_tombstones =
2341            std::mem::take(&mut loaded.managed_worktree_tombstones);
2342        let materializations = std::mem::take(&mut loaded.materializations);
2343        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2344        let (workspaces, records, persistence_warning) =
2345            merge_durable_state(&config.workspaces, loaded)?;
2346        let mut shared = NodeShared::new_with_incarnation(
2347            handle,
2348            config.access_token.clone(),
2349            config.node_id.clone(),
2350            incarnation_id,
2351            workspaces,
2352            enabled_providers,
2353            provider_runtime_statuses,
2354            provider_runtime_monitor,
2355            provider_contracts,
2356            provider_adapter_contracts,
2357            config.spawn_profiles.clone(),
2358            Some(native_launch_profile_control),
2359            native_session_catalog,
2360            session_environment_materializer,
2361            config.state_path.clone(),
2362            records,
2363            managed_worktrees,
2364            managed_worktree_tombstones,
2365            materializations,
2366            persistence_warning,
2367            input_settle_timeout_ms,
2368        );
2369        shared.harness_mcp_registry = harness_mcp_registry.clone();
2370        shared.session_record_retention = config.session_record_retention;
2371        shared.network_allowlist_catalog =
2372            RwLock::new(config.network_allowlist_catalog.clone());
2373        shared.bundle_catalog = RwLock::new(delivered_catalog);
2374        shared.delivery_store = Mutex::new(delivery_store);
2375        shared.context_catalog = RwLock::new(durable_context_catalog);
2376        shared.context_pack_store = Mutex::new(context_pack_store);
2377        shared.managed_spawn_replays = Mutex::new(
2378            managed_spawn_replays
2379                .into_iter()
2380                .map(|record| (record.idempotency_key.clone(), record))
2381                .collect(),
2382        );
2383        let shared = Arc::new(shared);
2384        if let Some(registry) = harness_mcp_registry {
2385            let weak = Arc::downgrade(&shared);
2386            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2387                Some(shared) => shared.publish_transient(event),
2388                None => false,
2389            }));
2390        }
2391        if shared.persistence_error().is_none() {
2392            shared.persist_state().map_err(|error| {
2393                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2394            })?;
2395        }
2396        Ok(Self {
2397            config,
2398            runtime,
2399            shared,
2400            events,
2401            state_path_lock,
2402        })
2403    }
2404
2405    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2406        NodeShutdownHandle {
2407            shared: Arc::clone(&self.shared),
2408        }
2409    }
2410
2411    #[cfg(feature = "fixture")]
2412    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2413        &self,
2414    ) -> SpawnManagedWorktreeV2FailureProbe {
2415        self.shared
2416            .fixture_spawn_managed_worktree_v2_failure_probe
2417            .clone()
2418    }
2419
2420    /// Installs one immutable host-local environment profile before the server runs.
2421    ///
2422    /// Native resolvers remain inside the runtime; the shared node registry keeps
2423    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2424    pub fn install_environment_profile(
2425        &mut self,
2426        profile: NodeEnvironmentProfile,
2427    ) -> Result<(), NodeServerError> {
2428        if !self.shared.enabled_providers.contains(profile.provider()) {
2429            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2430                profile.provider().clone(),
2431            ));
2432        }
2433        let (binding, native_profiles, materialization) = profile.into_parts();
2434        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2435            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2436        }
2437        let mut profiles = self
2438            .shared
2439            .environment_profiles
2440            .write()
2441            .unwrap_or_else(|poisoned| poisoned.into_inner());
2442        if profiles.contains_key(&binding.id) {
2443            return Err(NodeServerError::DuplicateEnvironmentProfile(
2444                binding.id.clone(),
2445            ));
2446        }
2447        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2448            return Err(NodeServerError::EnvironmentProfileCapacity {
2449                max: MAX_NODE_ENVIRONMENT_PROFILES,
2450            });
2451        }
2452        for native_id in binding.native_profile_ids() {
2453            if profiles.values().any(|current| {
2454                current.native_profile_ids().any(|current_id| current_id == native_id)
2455            }) {
2456                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2457                    native_id.to_string(),
2458                ));
2459            }
2460        }
2461
2462        let mut installed = Vec::with_capacity(native_profiles.len());
2463        for native_profile in native_profiles {
2464            let native_id = native_profile.id().clone();
2465            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2466                for installed_id in installed.into_iter().rev() {
2467                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2468                }
2469                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2470            }
2471            installed.push(native_id);
2472        }
2473        if let Some(materialization) = materialization {
2474            self.shared
2475                .environment_materialization_profiles
2476                .write()
2477                .unwrap_or_else(|poisoned| poisoned.into_inner())
2478                .insert(binding.id.clone(), materialization);
2479        }
2480        profiles.insert(binding.id.clone(), binding);
2481        Ok(())
2482    }
2483
2484    /// Installs one immutable host-local bundle before the server runs.
2485    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2486        if self.shared.session_environment_materializer.is_none() {
2487            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2488        }
2489        let mut catalog = self
2490            .shared
2491            .bundle_catalog
2492            .write()
2493            .unwrap_or_else(|poisoned| poisoned.into_inner());
2494        catalog
2495            .insert_idempotent(bundle)
2496            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2497        Ok(())
2498    }
2499
2500    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2501        let shutdown = self.shutdown_handle();
2502        let run = self.run();
2503        tokio::pin!(run);
2504        tokio::select! {
2505            result = &mut run => result,
2506            signal = wait_for_ctrl_signal() => {
2507                signal?;
2508                let shutdown_result = shutdown.request_shutdown().await;
2509                let run_result = run.await;
2510                shutdown_result.and(run_result)
2511            }
2512        }
2513    }
2514
2515    pub async fn run(self) -> Result<(), NodeServerError> {
2516        let NodeServer {
2517            config,
2518            mut runtime,
2519            shared,
2520            events,
2521            state_path_lock: _state_path_lock,
2522        } = self;
2523        shared.reconcile_materializations();
2524        shared.reconcile_managed_worktrees().await;
2525        shared.retire_unavailable_session_records().await;
2526        shared.reconcile_context_pack_exports().await;
2527        {
2528            let restore = Arc::clone(&shared);
2529            tokio::spawn(async move {
2530                sleep(Duration::from_secs(3)).await;
2531                restore.restore_journalled_sessions().await;
2532                loop {
2533                    restore.refresh_session_journal();
2534                    if restore.shutdown.load(Ordering::Acquire) { break; }
2535                    sleep(Duration::from_secs(10)).await;
2536                }
2537            });
2538        }
2539        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2540        // observes sessions through ACP where a provider has it; there is no
2541        // hook ingress.
2542        let endpoint = config.endpoint.clone();
2543        let call_home = config.call_home;
2544        let call_home_identity = config.call_home_identity.clone();
2545        let api_listen = config.api_listen;
2546        let bridge_listen = config.bridge_listen;
2547        let bridge_underlay = config.bridge_underlay;
2548        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2549            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2550        let accept_shared = Arc::clone(&shared);
2551        let api_shared = Arc::clone(&shared);
2552        let bridge_shared = Arc::clone(&shared);
2553        let shutdown_shared = Arc::clone(&shared);
2554        let shutdown_timeout = Duration::from_millis(
2555            config.runtime.provider_shutdown_timeout_ms.max(1),
2556        );
2557        let result = {
2558            let runtime_loop = drive_runtime_until_shutdown(
2559                &mut runtime,
2560                events,
2561                Arc::clone(&shared),
2562                shutdown_timeout,
2563            );
2564            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2565            let api_loop = http_api::run(api_listen, api_shared);
2566            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2567            tokio::pin!(runtime_loop);
2568            tokio::pin!(accept_loop);
2569            tokio::pin!(api_loop);
2570            tokio::pin!(bridge_loop);
2571            tokio::select! {
2572                runtime_result = &mut runtime_loop => {
2573                    let shutdown_result = shutdown_shared
2574                        .begin_shutdown()
2575                        .await
2576                        .map_err(NodeServerError::ShutdownDispatch);
2577                    let (accept_result, api_result, bridge_result) =
2578                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2579                    match runtime_result {
2580                        Err(error) => Err(error),
2581                        Ok(()) => shutdown_result
2582                            .and(accept_result)
2583                            .and(api_result.map_err(NodeServerError::HttpApi))
2584                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2585                    }
2586                }
2587                accept_result = &mut accept_loop => {
2588                    let shutdown_result = shutdown_shared
2589                        .begin_shutdown()
2590                        .await
2591                        .map_err(NodeServerError::ShutdownDispatch);
2592                    let (runtime_result, api_result, bridge_result) =
2593                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2594                    match accept_result {
2595                        Err(error) => Err(error),
2596                        Ok(()) => shutdown_result
2597                            .and(runtime_result)
2598                            .and(api_result.map_err(NodeServerError::HttpApi))
2599                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600                    }
2601                }
2602                api_result = &mut api_loop => {
2603                    let shutdown_result = shutdown_shared
2604                        .begin_shutdown()
2605                        .await
2606                        .map_err(NodeServerError::ShutdownDispatch);
2607                    let (runtime_result, accept_result, bridge_result) =
2608                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2609                    match api_result {
2610                        Err(error) => Err(NodeServerError::HttpApi(error)),
2611                        Ok(()) => shutdown_result
2612                            .and(runtime_result)
2613                            .and(accept_result)
2614                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615                    }
2616                }
2617                bridge_result = &mut bridge_loop => {
2618                    let shutdown_result = shutdown_shared
2619                        .begin_shutdown()
2620                        .await
2621                        .map_err(NodeServerError::ShutdownDispatch);
2622                    let (runtime_result, accept_result, api_result) =
2623                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2624                    match bridge_result {
2625                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2626                        Ok(()) => shutdown_result
2627                            .and(runtime_result)
2628                            .and(accept_result)
2629                            .and(api_result.map_err(NodeServerError::HttpApi)),
2630                    }
2631                }
2632            }
2633        };
2634        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2635        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2636        // NodeShutdownHandle also clones that Arc, so without an explicit release
2637        // here a supervisor that keeps the shutdown handle cannot reopen the same
2638        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2639        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2640        if let Ok(mut guard) = shared.delivery_store.lock() {
2641            *guard = None;
2642        }
2643        if let Ok(mut guard) = shared.context_pack_store.lock() {
2644            *guard = None;
2645        }
2646        result
2647    }
2648}
2649
2650async fn drive_runtime_until_shutdown(
2651    runtime: &mut NativeRuntime,
2652    events: EventSubscription,
2653    shared: Arc<NodeShared>,
2654    shutdown_timeout: Duration,
2655) -> Result<(), NodeServerError> {
2656    // Published once, never per iteration. The worker loops inside
2657    // `NativeRuntime` fold into this profile continuously; turning it into
2658    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2659    let _ = shared
2660        .shell_efficiency_profile
2661        .set(runtime.shell_efficiency_profile());
2662    let mut shutdown_started = None;
2663    let mut last_retention_sweep = Instant::now();
2664    let mut last_provisional_reap = Instant::now();
2665    loop {
2666        // Every phase below is timed unconditionally -- see
2667        // `drive_loop_profile`'s own doc comment for why an always-on,
2668        // allocation-free ring is cheap enough to never need a feature
2669        // flag. This is the only place these numbers can be taken: the
2670        // phases are calls made directly from this loop, not something a
2671        // callee could time on our behalf.
2672        let runtime_tick_start = Instant::now();
2673        let tick_result = runtime.tick().await;
2674        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2675
2676        let event_drain_start = Instant::now();
2677        // Every command the kernel refused, named. `NativeRuntimeTick`
2678        // has carried these outcomes all along and nothing read them, so a
2679        // rejected command -- a hook event denied by runtime policy, a
2680        // stale generation, an unsupported capability -- vanished at the
2681        // last step of the loop that produced it. A refusal the operator
2682        // cannot see is indistinguishable from an event that never arrived,
2683        // and telling those two apart is most of debugging this stack.
2684        for outcome in &tick_result.command_outcomes {
2685            if let Err(error) = &outcome.result {
2686                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2687                tracing::warn!(
2688                    command_id = outcome.command_id.0,
2689                    cause = %error,
2690                    "control command rejected"
2691                );
2692                shared.record_rejected_command(
2693                    outcome.command_id,
2694                    error.to_string(),
2695                    error.is_unsupported_transport(),
2696                );
2697            }
2698        }
2699        let mut events_drained: u64 = 0;
2700        while let Ok(event) = events.try_recv() {
2701            events_drained += 1;
2702            let clean_exit = matches!(
2703                event.event,
2704                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2705            );
2706            let instance_id = event.instance_id;
2707            let generation = event.generation;
2708            // Resolve and spawn before publish_control: publish_control's own
2709            // managed-record bookkeeping (reconcile_managed_record) downgrades
2710            // this exact record from Live to Dormant and clears
2711            // active_session for every Exited event, inside the same
2712            // synchronous call. Capturing (record_id, session) here — before
2713            // that runs — is the only way to observe them still Live and
2714            // still populated; the export itself must still run on its own
2715            // detached task (never awaited inline on this loop) because it
2716            // needs NativeRuntime::tick() to keep running to ever settle, and
2717            // tick() is only ever driven by this same loop. See
2718            // reconcile_context_pack_export_for_record's doc comment.
2719            if clean_exit {
2720                if let Some((record_id, session)) =
2721                    shared.managed_record_export_target_for_instance(instance_id, generation)
2722                {
2723                    let export_shared = Arc::clone(&shared);
2724                    tokio::spawn(async move {
2725                        export_shared
2726                            .reconcile_context_pack_export_for_record(&record_id, session)
2727                            .await;
2728                    });
2729                }
2730            }
2731            shared.publish_control(event);
2732        }
2733        let event_drain_elapsed = event_drain_start.elapsed();
2734
2735        let publish_terminal_frames_start = Instant::now();
2736        shared.publish_terminal_frames();
2737        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2738
2739        // Physical PTY count and the runtime's own per-phase tick timings
2740        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2741        // only `NodeShared`) cannot reach directly -- this loop is the one
2742        // place holding `&mut NativeRuntime`, so it is the only place that
2743        // can copy either out.
2744        shared
2745            .native_session_gauge
2746            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2747        *shared
2748            .runtime_tick_profile
2749            .lock()
2750            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2751
2752        let remainder_start = Instant::now();
2753        if shared.shutdown.load(Ordering::Acquire) {
2754            let started = *shutdown_started.get_or_insert_with(Instant::now);
2755            let snapshot = shared.handle.snapshot();
2756            let sessions_settled = snapshot.sessions.iter().all(|session| {
2757                !matches!(
2758                    session.status,
2759                    gate4agent_types::SessionStatus::Starting
2760                        | gate4agent_types::SessionStatus::Running
2761                        | gate4agent_types::SessionStatus::Stopping
2762                )
2763            });
2764            if runtime.active_native_sessions() == 0 && sessions_settled {
2765                return Ok(());
2766            }
2767            if started.elapsed() >= shutdown_timeout {
2768                return Err(NodeServerError::ShutdownTimedOut {
2769                    active_native_sessions: runtime.active_native_sessions(),
2770                });
2771            }
2772        }
2773        let remainder_elapsed = remainder_start.elapsed();
2774
2775        {
2776            let mut profile = shared
2777                .drive_loop_profile
2778                .lock()
2779                .unwrap_or_else(|poisoned| poisoned.into_inner());
2780            profile.record_runtime_tick(runtime_tick_elapsed);
2781            profile.record_event_drain(event_drain_elapsed);
2782            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2783            profile.record_remainder(remainder_elapsed);
2784            profile.note_iteration(Instant::now());
2785        }
2786
2787        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2788        // applied zero observations (`tick_result.observations_applied`,
2789        // which already folds in any terminal frames collected -- see
2790        // `NativeEffectDispatcher::drain_observations`) AND the
2791        // control-event subscription drained above forwarded zero events.
2792        // Command ingress and dispatched effects are deliberately NOT part
2793        // of this definition: an idle node still runs `step_control_plane`
2794        // and every provider supervisor's `tick` regardless, every 10ms --
2795        // so "nothing" here means nothing OBSERVABLE left this iteration,
2796        // matching what a reader would assume from `drive_loop_iterations`'s
2797        // own field name, not that the tick itself was free of work.
2798        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2799        if tick_result.observations_applied == 0 && events_drained == 0 {
2800            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2801        }
2802
2803        // A sweep cadence, not a policy value -- see
2804        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2805        // policy itself (whether this does anything at all) lives entirely
2806        // in `NodeShared::session_record_retention`.
2807        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2808            shared.retire_unavailable_session_records().await;
2809            last_retention_sweep = Instant::now();
2810        }
2811        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2812        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2813            for address in shared.take_due_provisional_spawns() {
2814                shared.reap_provisional_spawn(address).await;
2815            }
2816            last_provisional_reap = Instant::now();
2817        }
2818
2819        // Fast-poll for 300ms after PTY input so its echo is read and published
2820        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2821        let hot = shared
2822            .pty_input_marks
2823            .lock()
2824            .unwrap_or_else(|poisoned| poisoned.into_inner())
2825            .values()
2826            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2827        if hot {
2828            sleep(Duration::from_millis(1)).await;
2829        } else {
2830            tokio::select! {
2831                _ = sleep(Duration::from_millis(10)) => {}
2832                _ = shared.pty_input_wake.notified() => {}
2833            }
2834        }
2835    }
2836}
2837
2838#[derive(Clone)]
2839pub struct NodeShutdownHandle {
2840    shared: Arc<NodeShared>,
2841}
2842
2843impl NodeShutdownHandle {
2844    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2845        self.shared
2846            .begin_shutdown()
2847            .await
2848            .map_err(NodeServerError::ShutdownDispatch)
2849    }
2850}
2851
2852async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2853    #[cfg(windows)]
2854    {
2855    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2856    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2857    return tokio::select! {
2858        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2859        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2860    };
2861    }
2862    #[cfg(unix)]
2863    {
2864        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2865        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2866        tokio::select! {
2867            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2869        }
2870    }
2871}
2872
2873/// The providers this node actually runs.
2874///
2875/// `builtin_registry()` is a reference catalog of everything the project has
2876/// ever described; the fleet is this list, and it is deliberately short. A
2877/// provider only belongs here if it can be exercised for real -- there is a
2878/// subscription behind it and a failure in it can be reproduced and
2879/// debugged. Carrying one that cannot is carrying code nobody can verify,
2880/// which is worse than not supporting it: it looks supported.
2881///
2882/// A provider dropped from the fleet by this rule is removed outright, not
2883/// carried dark: no catalog entry, no adapter, no dead code claiming support
2884/// for something nobody can verify runs.
2885/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2886fn shell_host_spec() -> AgentSpec {
2887    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2888    // fleet inventory on op-box resolves `bash` to the WSL stub at
2889    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2890    #[cfg(windows)]
2891    let (detect_command, program, fixed_args, process_name) = {
2892        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2893            r"C:\Windows\System32\cmd.exe".to_owned()
2894        });
2895        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2896    };
2897    #[cfg(not(windows))]
2898    let (detect_command, program, fixed_args, process_name) = (
2899        "bash".to_owned(),
2900        "/bin/bash".to_owned(),
2901        vec!["-i".to_owned()],
2902        "bash".to_owned(),
2903    );
2904    AgentSpec {
2905        id: AgentId::new("shell").expect("shell agent id"),
2906        revision: "hatchery:shell-pty-1".to_owned(),
2907        display_name: "Host shell".to_owned(),
2908        detection: DetectionSpec {
2909            command: detect_command,
2910            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2911            required_commands: Vec::new(),
2912            unsupported_platforms: Vec::new(),
2913        },
2914        launch: LaunchSpec {
2915            program,
2916            fixed_args,
2917        },
2918        expected_processes: vec![ProcessMatcher::Exact {
2919            name: process_name,
2920        }],
2921        prompt: PromptSpec {
2922            initial: InitialPromptMode::None,
2923            native_draft: None,
2924        },
2925        readiness: AgentReadinessSpec::default(),
2926        capabilities: AgentCapabilities {
2927            agent_commands: None,
2928            transports: AgentTransportCapabilities {
2929                pty: true,
2930                pty_adapter: None,
2931                pipe: None,
2932                acp: None,
2933            },
2934            adapters: AgentAdapterCapabilities::default(),
2935        },
2936        verification: SpecVerification::Gate4AgentVerified,
2937    }
2938}
2939
2940fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2941    let mut specs = vec![shell_host_spec()];
2942    specs.extend(
2943        builtin_registry()
2944            .iter()
2945            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2946            .cloned(),
2947    );
2948    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2949}
2950
2951fn provider_contract_manifest(
2952    catalog: &AgentRegistry,
2953) -> Result<
2954    (
2955        Vec<ProviderContractSupport>,
2956        Vec<ProviderAdapterContractSupport>,
2957    ),
2958    NodeServerError,
2959> {
2960    let mut provider_contracts = Vec::new();
2961    let mut provider_adapter_contracts = Vec::new();
2962    for spec in catalog.iter() {
2963        let provider = spec.id.clone();
2964        let revision = ProviderContractRevision::new(spec.revision.clone())
2965            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2966        provider_contracts.push(ProviderContractSupport {
2967            provider: provider.clone(),
2968            revision,
2969        });
2970        for (family, binding) in declared_adapter_bindings(spec)? {
2971            let revision = AdapterContractRevision::new(binding.revision.clone())
2972                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2973            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2974                provider: provider.clone(),
2975                family,
2976                adapter_id: binding.id.clone(),
2977                revision,
2978            });
2979        }
2980    }
2981    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2982        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2983    Ok((provider_contracts, provider_adapter_contracts))
2984}
2985
2986fn declared_adapter_bindings(
2987    spec: &AgentSpec,
2988) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2989    let mut bindings = Vec::new();
2990    push_declared_binding(
2991        &mut bindings,
2992        AdapterFamily::PtySemantic,
2993        spec.capabilities.transports.pty_adapter.as_ref(),
2994    )?;
2995    push_declared_binding(
2996        &mut bindings,
2997        AdapterFamily::Pipe,
2998        spec.capabilities.adapters.pty_sidecar.as_ref(),
2999    )?;
3000    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3001    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3002        gate4agent_types::PipeProtocol::SemanticNdjson
3003        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3004        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3005    });
3006    push_declared_binding(
3007        &mut bindings,
3008        AdapterFamily::Pipe,
3009        pipe_transport
3010            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3011            .map(|transport| &transport.adapter),
3012    )?;
3013    push_declared_binding(
3014        &mut bindings,
3015        AdapterFamily::Acp,
3016        spec.capabilities
3017            .transports
3018            .acp
3019            .as_ref()
3020            .map(|transport| &transport.adapter),
3021    )?;
3022    push_declared_binding(
3023        &mut bindings,
3024        AdapterFamily::Hook,
3025        spec.capabilities.adapters.hook.as_ref(),
3026    )?;
3027    push_declared_binding(
3028        &mut bindings,
3029        AdapterFamily::ManagedHook,
3030        spec.capabilities.adapters.managed_hook.as_ref(),
3031    )?;
3032    push_declared_binding(
3033        &mut bindings,
3034        AdapterFamily::OneShot,
3035        pipe_transport
3036            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3037            .map(|transport| &transport.adapter),
3038    )?;
3039    push_declared_binding(
3040        &mut bindings,
3041        AdapterFamily::OneShot,
3042        spec.capabilities.adapters.one_shot.as_ref(),
3043    )?;
3044    push_declared_binding(
3045        &mut bindings,
3046        AdapterFamily::History,
3047        spec.capabilities.adapters.history.as_ref(),
3048    )?;
3049    push_declared_binding(
3050        &mut bindings,
3051        AdapterFamily::Resume,
3052        spec.capabilities.adapters.resume.as_ref(),
3053    )?;
3054    push_declared_binding(
3055        &mut bindings,
3056        AdapterFamily::SessionOptions,
3057        spec.capabilities.adapters.session_options.as_ref(),
3058    )?;
3059    push_declared_binding(
3060        &mut bindings,
3061        AdapterFamily::CapabilityProbe,
3062        spec.capabilities.adapters.capability_probe.as_ref(),
3063    )?;
3064    Ok(bindings)
3065}
3066
3067fn push_declared_binding<'a>(
3068    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3069    family: AdapterFamily,
3070    binding: Option<&'a AdapterBinding>,
3071) -> Result<(), NodeServerError> {
3072    let Some(binding) = binding else {
3073        return Ok(());
3074    };
3075    if let Some((_, existing)) = bindings
3076        .iter()
3077        .find(|(existing_family, _)| *existing_family == family)
3078    {
3079        if *existing == binding {
3080            return Ok(());
3081        }
3082        return Err(NodeServerError::ProviderContractManifest(format!(
3083            "provider declares conflicting {family:?} adapter bindings"
3084        )));
3085    }
3086    bindings.push((family, binding));
3087    Ok(())
3088}
3089
3090fn merge_durable_state(
3091    configured: &[WorkspaceConfig],
3092    mut loaded: LoadedNodeState,
3093) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3094    let mut workspaces = configured
3095        .iter()
3096        .cloned()
3097        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3098        .collect::<BTreeMap<_, _>>();
3099    let mut roots = workspaces
3100        .values()
3101        .map(|workspace| {
3102            (
3103                platform::root_identity(&workspace.canonical_root),
3104                workspace.workspace_id.clone(),
3105            )
3106        })
3107        .collect::<BTreeMap<_, _>>();
3108    let mut warning = loaded
3109        .warning
3110        .take()
3111        .map(|message| session_registry::sanitized_persistence_summary(&message));
3112
3113    for (workspace_id, canonical_root) in loaded.workspaces {
3114        if let Some(existing) = workspaces.get(&workspace_id) {
3115            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3116                return Err(NodeServerError::DurableState(io::Error::new(
3117                    io::ErrorKind::InvalidData,
3118                    DURABLE_STATE_CONFLICT_ERROR,
3119                )));
3120            }
3121            continue;
3122        }
3123        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3124            return Err(NodeServerError::DurableState(io::Error::new(
3125                io::ErrorKind::InvalidData,
3126                DURABLE_STATE_CONFLICT_ERROR,
3127            )));
3128        }
3129        if !Path::new(&canonical_root).is_dir() {
3130            warning.get_or_insert_with(|| {
3131                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3132            });
3133            continue;
3134        }
3135        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3136            NodeServerError::DurableState(io::Error::new(
3137                io::ErrorKind::InvalidData,
3138                DURABLE_STATE_CONFLICT_ERROR,
3139            ))
3140        })?;
3141        roots.insert(
3142            platform::root_identity(&workspace.canonical_root),
3143            workspace.workspace_id.clone(),
3144        );
3145        workspaces.insert(workspace_id, workspace);
3146    }
3147
3148    for record in &mut loaded.records {
3149        match workspaces.get(&record.workspace_id) {
3150            Some(workspace)
3151                if platform::roots_equal(
3152                    &workspace.canonical_root,
3153                    windows_path_text(&record.canonical_root),
3154                ) => {}
3155            Some(_) => {
3156                return Err(NodeServerError::DurableState(io::Error::new(
3157                    io::ErrorKind::InvalidData,
3158                    DURABLE_STATE_CONFLICT_ERROR,
3159                )));
3160            }
3161            None => {
3162                record.state = ManagedSessionState::Unavailable;
3163                record.active_session = None;
3164                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3165            }
3166        }
3167    }
3168    Ok((workspaces.into_values().collect(), loaded.records, warning))
3169}
3170
3171/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3172/// own doc for the mechanism this exists to close.
3173struct RejectedCommandReason {
3174    message: String,
3175    /// True when the kernel refused this command specifically because the
3176    /// agent does not declare the requested transport (`KernelCommandError::
3177    /// UnsupportedTransport`) -- lets the reader choose the named
3178    /// `NodeFailureCode::UnsupportedTransport` over the generic
3179    /// `BackendOperationFailed` bucket, so this one well-known rejection
3180    /// keeps its identity all the way to the operator wire.
3181    unsupported_transport: bool,
3182    expires_at: Instant,
3183}
3184
3185struct NodeShared {
3186    handle: Gate4AgentHandle,
3187    access_token: String,
3188    node_id: NodeId,
3189    incarnation_id: NodeIncarnationId,
3190    started_at_unix_ms: u64,
3191    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3192    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3193    managed_worktree_profiles:
3194        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3195    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3196    enabled_providers: Vec<AgentId>,
3197    provider_runtime_statuses: ProviderRuntimeStatuses,
3198    provider_runtime_status_updates:
3199        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3200    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3201    provider_contracts: Vec<ProviderContractSupport>,
3202    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3203    spawn_profiles: SpawnProfileRegistry,
3204    environment_profiles:
3205        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3206    environment_materialization_profiles:
3207        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3208    /// Station **network** allowlist catalog (opaque policy ids only).
3209    /// Empty by default — any `network_allowlist` on spawn must be registered
3210    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3211    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3212    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3213    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3214    /// Never holds cookies / OAuth / proxy credentials.
3215    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3216    /// Node-local exclusive dig2browser station profile leases (feature
3217    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3218    /// cleared on session end. Never ImportSession / cookies on C2.
3219    #[cfg(feature = "dig2-station-probe")]
3220    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3221    bundle_catalog: RwLock<BundleCatalog>,
3222    delivery_store: Mutex<Option<DeliveryStore>>,
3223    context_pack_store: Mutex<Option<ContextPackStore>>,
3224    context_catalog: RwLock<ContextPackCatalog>,
3225    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3226    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3227    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3228    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3229    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3230    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3231    /// nick -> the session that holds that m4a account (for letter wakes).
3232    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3233    /// m4a session key per runtime this node gave an account.
3234    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3235    /// Keys a resume hands to its new runtime (same nick and mailbox).
3236    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3237    /// Per-session grok leader sockets (never the operator's shared leader).
3238    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3239    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3240    /// instead of a bearer capability.
3241    #[cfg(unix)]
3242    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3243    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3244    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3245    managed_spawn_replays:
3246        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3247    controller: Mutex<Option<ControllerLease>>,
3248    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3249    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3250    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3251    /// ACP launch options waiting for the agent to advertise its config options.
3252    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3253    history: Mutex<NodeEventHistory>,
3254    event_tx: broadcast::Sender<NodeEventEnvelope>,
3255    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3256    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3257    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3258    /// exactly rather than riding the durable `event_tx` history (see
3259    /// `publish`'s own assertion): a subscriber who never negotiated
3260    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3261    /// these, the same guarantee `include_terminal_frame_events` already
3262    /// gives terminal frames.
3263    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3265    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3266    /// broadcast rather than a ride on the durable `event_tx` history. A
3267    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3268    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3269    /// finite capacity and its willingness to silently evict a lagging
3270    /// subscriber's backlog is exactly how the call vanished with neither
3271    /// side ever logging it (see `publish_transient`'s own doc).
3272    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3273    terminal_frame_watermarks:
3274        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3275    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3276    pty_input_wake: tokio::sync::Notify,
3277    /// Per session: when the last PTY input was dispatched (latency probe +
3278    /// a short fast-poll window so the echo is published within ~1ms).
3279    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3280    next_connection_id: AtomicU64,
3281    next_instance_id: AtomicU64,
3282    next_command_id: AtomicU64,
3283    shutdown: AtomicBool,
3284    shutdown_notify: Notify,
3285    preauth_slots: Arc<Semaphore>,
3286    authenticated_slots: Arc<Semaphore>,
3287    inspection_slots: Arc<Semaphore>,
3288    mutation_gate: AsyncMutex<()>,
3289    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3290    session_records: Mutex<SessionRecords>,
3291    state_path: Option<PathBuf>,
3292    persistence_error: RwLock<Option<String>>,
3293    state_transaction: Mutex<()>,
3294    input_settle_timeout_ms: u64,
3295    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3296    /// comment. Written once per iteration by
3297    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3298    drive_loop_profile: Mutex<DriveLoopProfiler>,
3299    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3300    /// so an overwrite each iteration is cheap. The runtime itself is not
3301    /// reachable from here (it is owned locally by
3302    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3303    /// tick timings reach `GET /metrics`.
3304    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3305    /// The live shell-efficiency profile, shared with the per-instance
3306    /// worker loops that fold into it.
3307    ///
3308    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3309    /// profile` above keeps: producing a snapshot sorts every ring behind
3310    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3311    /// field set at construction because `NodeShared` is built before the
3312    /// `NativeRuntime` that owns the profile exists -- the drive loop
3313    /// publishes it once, before its first iteration. Unset means the
3314    /// runtime has not started driving yet, which `GET /metrics` reports as
3315    /// empty series rather than as zeroes it never measured.
3316    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3317    /// Physical PTY sessions the native runtime currently owns
3318    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3319    /// loop iteration -- deliberately a separate count from
3320    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3321    /// session bookkeeping), since the two can disagree and the gap is
3322    /// itself a diagnostic signal `GET /metrics` exposes.
3323    native_session_gauge: AtomicUsize,
3324    /// Lifetime totals for the per-connection serve loop, summed across
3325    /// every live connection.
3326    ///
3327    /// The drive loop's own phase timings account for about ten
3328    /// microseconds an iteration on an idle node, yet the process burns a
3329    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3330    /// with none, 23% with two, measured). That gap is somewhere in
3331    /// serving a connection, and there was no way to see it: iterations
3332    /// says whether the loop is spinning rather than sleeping, and events
3333    /// says whether it is spinning over real work or over nothing.
3334    connection_loop_iterations: AtomicU64,
3335    connection_events_sent: AtomicU64,
3336    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3337    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3338    /// `NativeRuntime::tick` observations AND forwarded zero control
3339    /// events; see the exact "produced nothing" definition at the site in
3340    /// `drive_runtime_until_shutdown` that increments these. Read twice
3341    /// and divide by the interval, same rule as `connections` above --
3342    /// `idle / total` is backlog item 5's "wake on work" number.
3343    /// Lifetime count of control commands the kernel refused.
3344    rejected_commands_total: AtomicU64,
3345    /// The named reason behind a recent kernel rejection, by the
3346    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3347    /// command -- populated by `drive_runtime_until_shutdown` for every
3348    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3349    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3350    /// `CommandId` among them.
3351    ///
3352    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3353    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3354    /// problem -- full or disconnected -- never for the kernel refusing the
3355    /// command's content (an unsupported transport, an unknown agent, ...);
3356    /// that verdict exists only once `NativeRuntime::tick` has processed the
3357    /// command, one drive-loop iteration later, in a different task than
3358    /// whichever caller is awaiting the spawn. Without this map, a spawn
3359    /// wait loop that polls `self.handle.snapshot()` for a session that
3360    /// will now never appear has no way to learn why -- it can only spin
3361    /// until its own commit deadline and report a generic "did not commit"
3362    /// timeout, discarding a reason the kernel already named precisely (see
3363    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3364    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3365    drive_loop_iterations_total: AtomicU64,
3366    drive_loop_iterations_idle: AtomicU64,
3367    /// The operator-chosen age/keep-N policy the retention sweep applies to
3368    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3369    /// see `SessionRecordRetentionConfig`'s own doc comment.
3370    session_record_retention: SessionRecordRetentionConfig,
3371    /// Lifetime count of managed session records the retention sweep has
3372    /// retired. Same tier as `rejected_commands_total` above: a live,
3373    /// process-lifetime instrument, never persisted and never reloaded from
3374    /// `state-v1.json` -- a restart legitimately resets it, matching every
3375    /// other `*_total` counter here.
3376    retired_records_total: AtomicU64,
3377    #[cfg(feature = "fixture")]
3378    fixture_semantic_hook_policy: bool,
3379    #[cfg(feature = "fixture")]
3380    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3381}
3382
3383impl Drop for NodeShared {
3384    fn drop(&mut self) {
3385        SessionLifecycleHook::on_shutdown(self);
3386    }
3387}
3388
3389#[derive(Clone, Debug, Eq, PartialEq)]
3390struct SessionBinding {
3391    workspace_id: WorkspaceId,
3392    generation: SessionGeneration,
3393    runtime_policy: ProviderRuntimePolicy,
3394    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3395    record_id: Option<SessionRecordId>,
3396    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3397    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3398    bundle: Option<ResolvedBundleReceipt>,
3399    context: Option<ResolvedContextPackReceipt>,
3400    materialization_id: Option<MaterializationId>,
3401}
3402
3403#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3404enum SpawnRecordPolicy {
3405    ProviderIdentityOnly,
3406    Always,
3407}
3408
3409struct NativeEnvironmentSelectionGuard {
3410    control: Option<NativeLaunchProfileControl>,
3411    instance_id: AgentInstanceId,
3412}
3413
3414struct NativeInstanceOverlayGuard {
3415    control: Option<NativeLaunchProfileControl>,
3416    instance_id: AgentInstanceId,
3417}
3418
3419struct NativeHarnessMcpOverlayGuard {
3420    control: NativeLaunchProfileControl,
3421    instance_id: AgentInstanceId,
3422}
3423
3424impl NativeHarnessMcpOverlayGuard {
3425    fn retain(self) { std::mem::forget(self); }
3426}
3427
3428impl Drop for NativeHarnessMcpOverlayGuard {
3429    fn drop(&mut self) {
3430        self.control
3431            .clear_native_mcp_server_launch_overlay(self.instance_id);
3432    }
3433}
3434
3435impl NativeInstanceOverlayGuard {
3436    fn retain(mut self) {
3437        self.control = None;
3438    }
3439}
3440
3441impl Drop for NativeInstanceOverlayGuard {
3442    fn drop(&mut self) {
3443        if let Some(control) = self.control.as_ref() {
3444            control.clear_native_instance_launch_overlay(self.instance_id);
3445        }
3446    }
3447}
3448
3449enum PreparedNativeLaunchOverlay {
3450    Environment(NativeLaunchEnvironmentOverlay),
3451    Instance(NativeInstanceLaunchOverlay),
3452}
3453
3454impl NativeEnvironmentSelectionGuard {
3455    fn retain(mut self) {
3456        self.control = None;
3457    }
3458}
3459
3460impl Drop for NativeEnvironmentSelectionGuard {
3461    fn drop(&mut self) {
3462        if let Some(control) = self.control.as_ref() {
3463            control.clear_native_launch_profile_selection(self.instance_id);
3464        }
3465    }
3466}
3467
3468struct SessionMaterializationGuard<'a> {
3469    shared: &'a NodeShared,
3470    id: Option<MaterializationId>,
3471    /// Absolute ProviderHome (`home/`) under the session materialization root.
3472    /// Used for Claude station network `--settings` sidecar writes.
3473    provider_home: Option<PathBuf>,
3474}
3475
3476impl SessionMaterializationGuard<'_> {
3477    fn id(&self) -> Option<&MaterializationId> {
3478        self.id.as_ref()
3479    }
3480
3481    fn provider_home(&self) -> Option<&Path> {
3482        self.provider_home.as_deref()
3483    }
3484
3485    fn retain(mut self) {
3486        self.id = None;
3487    }
3488}
3489
3490impl Drop for SessionMaterializationGuard<'_> {
3491    fn drop(&mut self) {
3492        if let Some(id) = self.id.take() {
3493            let _ = self.shared.cleanup_materialization(&id);
3494        }
3495    }
3496}
3497
3498
3499/// Port: product-side reactions to session lifecycle transitions
3500/// (station lease, harness MCP overlay/registry, environment launch overlays).
3501trait SessionLifecycleHook {
3502    /// A binding was removed (unbind or spawn rollback).
3503    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3504    /// A session reached a terminal control event that revokes its harness MCP door.
3505    fn on_session_ended(&self, address: &SessionAddress);
3506    /// Node shutdown (or drop): revoke every harness MCP reservation.
3507    fn on_shutdown(&self);
3508    /// A harness MCP reservation was aborted before activation.
3509    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3510    /// Release session-owned materialization for a removed binding.
3511    fn cleanup_session_materialization(
3512        &self,
3513        address: &SessionAddress,
3514        binding: &SessionBinding,
3515    ) -> Result<(), NodeFailure>;
3516}
3517
3518impl SessionLifecycleHook for NodeShared {
3519    fn on_session_ended(&self, address: &SessionAddress) {
3520        self.retire_session_grok_leader(address.session.instance_id);
3521        if !self.shutdown.load(Ordering::Acquire) {
3522            if let Some(file) = self.session_resume_journal() {
3523                crate::pty_resume::remove(&file, address.session.instance_id.0);
3524            }
3525        }
3526        if let Some(accounts) = self.m4a_accounts.as_ref() {
3527            let instance = address.session.instance_id;
3528            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3529            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3530                accounts.remove_session_file(nick);
3531            }
3532        }
3533        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3534            registry.revoke_session(address);
3535        }
3536        if let Some(control) = self.native_launch_profile_control.as_ref() {
3537            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3538        }
3539    }
3540
3541    fn on_shutdown(&self) {
3542        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3543            .into_values()
3544            .collect();
3545        for socket in leaders {
3546            crate::grok_leader::retire(&socket);
3547        }
3548        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549            let instances = registry.shutdown();
3550            if let Some(control) = self.native_launch_profile_control.as_ref() {
3551                for instance_id in instances {
3552                    control.clear_native_mcp_server_launch_overlay(instance_id);
3553                }
3554            }
3555        }
3556    }
3557
3558    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3559        if let Some(control) = self.native_launch_profile_control.as_ref() {
3560            control.clear_native_mcp_server_launch_overlay(instance_id);
3561        }
3562    }
3563
3564    fn cleanup_session_materialization(
3565        &self,
3566        address: &SessionAddress,
3567        binding: &SessionBinding,
3568    ) -> Result<(), NodeFailure> {
3569        self.cleanup_session_owned_materialization(address, binding)
3570    }
3571
3572    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3573        // Exclusive dig2browser station profile lease is node-local; clear
3574        // on every session unbind / rollback. Never ImportSession/cookies.
3575        #[cfg(feature = "dig2-station-probe")]
3576        {
3577            crate::dig2_station_lease::release_lease_for_holder(
3578                &self.browser_station_leases,
3579                address.session.instance_id,
3580            );
3581        }
3582        if let Some(control) = self.native_launch_profile_control.as_ref() {
3583            control.clear_native_mcp_server_launch_overlay(
3584                address.session.instance_id,
3585            );
3586        }
3587        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3588            registry.revoke_session(address);
3589        }
3590        if removed
3591            .is_some_and(|binding| {
3592                binding.environment_profile.is_some()
3593                    || binding.bundle.is_some()
3594                    || binding.context.is_some()
3595            })
3596        {
3597            if let Some(control) = self.native_launch_profile_control.as_ref() {
3598                control.clear_native_launch_profile_selection(
3599                    address.session.instance_id,
3600                );
3601                control.clear_native_instance_launch_overlay(
3602                    address.session.instance_id,
3603                );
3604            }
3605        }
3606    }
3607}
3608
3609struct SpawnPrepareRequest<'r> {
3610    instance_id: AgentInstanceId,
3611    address: &'r SessionAddress,
3612    provider: &'r AgentId,
3613    mode: SessionMode,
3614    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3615    bundle: Option<&'r ResolvedBundleReceipt>,
3616    context: Option<&'r ResolvedContextPackReceipt>,
3617    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3618    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3619    approval_level: ApprovalLevel,
3620    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3621    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3622}
3623
3624/// Guards held between "session identity minted" and "launch committed".
3625/// Field order is the drop order and matches the pre-refactor local order
3626/// (instance overlay, environment selection, materialization, harness, station lease).
3627struct PreparedSpawn<'a> {
3628    instance_overlay: Option<NativeInstanceOverlayGuard>,
3629    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3630    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3631    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3632    #[cfg(feature = "dig2-station-probe")]
3633    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3634    materialization_id: Option<MaterializationId>,
3635}
3636
3637impl PreparedSpawn<'_> {
3638    /// Session-owned launch state now belongs to the binding.
3639    fn retain_session_guards(&mut self) {
3640        if let Some(selection) = self.environment_selection.take() {
3641            selection.retain();
3642        }
3643        if let Some(overlay) = self.instance_overlay.take() {
3644            overlay.retain();
3645        }
3646        if let Some(guard) = self.materialization_guard.take() {
3647            guard.retain();
3648        }
3649    }
3650
3651    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3652    fn retain_launch_guards(&mut self) {
3653        if let Some(overlay) = self.harness_mcp_overlay.take() {
3654            overlay.retain();
3655        }
3656        #[cfg(feature = "dig2-station-probe")]
3657        if let Some(guard) = self.browser_station_lease_guard.take() {
3658            guard.retain();
3659        }
3660    }
3661}
3662
3663/// Port: everything product-side that must be prepared before a session is
3664/// bound and started (station lease, harness MCP overlay, materialization,
3665/// environment selection, network overlays, launch overlay).
3666trait SpawnPreparer {
3667    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3668    /// Select the native launch profile for an environment-profile session.
3669    fn select_environment(
3670        &self,
3671        instance_id: AgentInstanceId,
3672        provider: &AgentId,
3673        mode: SessionMode,
3674        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3675    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3676    /// Install a prepared launch overlay for the instance.
3677    fn install_launch_overlay(
3678        &self,
3679        instance_id: AgentInstanceId,
3680        overlay: PreparedNativeLaunchOverlay,
3681    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3682}
3683
3684impl NodeShared {
3685    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3686    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3687    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3688        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3689        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3690        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3691        if let Some(file) = self.session_resume_journal() {
3692            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3693        }
3694        key
3695    }
3696
3697    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3698        let accounts = self.m4a_accounts.as_ref()?;
3699        let nick = self.m4a_nick_of(instance_id)?;
3700        match accounts.session_workdir(root, provider.as_str(), &nick) {
3701            Ok(dir) => dir,
3702            Err(error) => {
3703                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3704                None
3705            }
3706        }
3707    }
3708
3709    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3710        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3711    }
3712
3713    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3714    /// `/api/spawn` returns it).
3715    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3716        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3717        for record in records.records.values_mut() {
3718            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3719                record.display_name = nick.to_owned();
3720            }
3721        }
3722    }
3723
3724    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3725    /// the mailbox listener that wakes it.
3726    fn m4a_session_overlay(
3727        &self,
3728        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3729        m4a_provider: &str,
3730        provider: &AgentId,
3731        instance_id: AgentInstanceId,
3732        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3733        session_key: &str,
3734        mode: SessionMode,
3735    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3736        let session_key = session_key.to_owned();
3737        if mode == SessionMode::Acp {
3738            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3739        }
3740        Ok(                match accounts.provision(m4a_provider, &session_key) {
3741                    Ok(account) => {
3742                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3743                        let mut identity = account.env_mutations();
3744                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3745                        let merged = match environment_overlay {
3746                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3747                                .map(PreparedNativeLaunchOverlay::Instance),
3748                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3749                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3750                            }
3751                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3752                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3753                            }
3754                        }
3755                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3756                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3757                        let mut merged = merged;
3758                        match accounts.write_session_file(&account) {
3759                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3760                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3761                                Ok(args) if !args.is_empty() => {
3762                                    merged = match merged {
3763                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3764                                            .append_extra_args(args)
3765                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3766                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3767                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3768                                            .into_instance_with_extra_args(args)
3769                                            .map(PreparedNativeLaunchOverlay::Instance)
3770                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3771                                    };
3772                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3773                                }
3774                                Ok(_) => {}
3775                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3776                            },
3777                        }
3778                        self.m4a_live
3779                            .lock()
3780                            .unwrap_or_else(|p| p.into_inner())
3781                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3782                        self.name_record_after_nick(instance_id, &account.nick);
3783                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3784                        // Letters to this account wake the session: the node listens on its mailbox.
3785                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3786                        Some(merged)
3787                    }
3788                    Err(error) => {
3789                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3790                        environment_overlay
3791                    }
3792                })
3793    }
3794}
3795
3796impl NodeShared {
3797    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3798    /// handed to the agent in session/new, with the account env on that server only.
3799    fn m4a_acp_mcp_overlay(
3800        &self,
3801        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3802        m4a_provider: &str,
3803        provider: &AgentId,
3804        instance_id: AgentInstanceId,
3805        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3806        session_key: &str,
3807    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3808        let Some(control) = self.native_launch_profile_control.as_ref() else {
3809            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3810            return Ok(environment_overlay);
3811        };
3812        let account = match accounts.provision(m4a_provider, session_key) {
3813            Ok(a) => a,
3814            Err(error) => {
3815                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3816                return Ok(environment_overlay);
3817            }
3818        };
3819        let session_file = match accounts.write_session_file(&account) {
3820            Ok(p) => p,
3821            Err(error) => {
3822                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3823                return Ok(environment_overlay);
3824            }
3825        };
3826        let env_entries: Vec<(OsString, OsString)> = account
3827            .env_mutations()
3828            .into_iter()
3829            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3830            .filter_map(|m| m.value.map(|v| (m.key, v)))
3831            .collect();
3832        let args: Vec<OsString> = vec![
3833            "--session-file".into(),
3834            session_file.into_os_string(),
3835            "mcp".into(),
3836            "--provider".into(),
3837            provider.as_str().into(),
3838        ];
3839        let overlay = NativeMcpServerLaunchOverlay::new(
3840            provider.clone(),
3841            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3842            accounts.bin().as_os_str().to_os_string(),
3843            args,
3844            env_entries,
3845            Vec::new(),
3846        );
3847        match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3848            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3849            Err(error) => {
3850                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3851                return Ok(environment_overlay);
3852            }
3853        }
3854        self.m4a_live
3855            .lock()
3856            .unwrap_or_else(|p| p.into_inner())
3857            .insert(account.nick.clone(), (instance_id, provider.clone()));
3858        self.name_record_after_nick(instance_id, &account.nick);
3859        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3860        Ok(environment_overlay)
3861    }
3862}
3863
3864impl SpawnPreparer for NodeShared {
3865    fn select_environment(
3866        &self,
3867        instance_id: AgentInstanceId,
3868        provider: &AgentId,
3869        mode: SessionMode,
3870        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3871    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3872        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3873    }
3874
3875    fn install_launch_overlay(
3876        &self,
3877        instance_id: AgentInstanceId,
3878        overlay: PreparedNativeLaunchOverlay,
3879    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3880        self.install_prepared_launch_overlay(instance_id, overlay)
3881    }
3882
3883    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3884        let SpawnPrepareRequest {
3885            instance_id,
3886            address,
3887            provider,
3888            mode,
3889            environment_profile,
3890            bundle,
3891            context,
3892            managed_authority,
3893            harness_mcp,
3894            approval_level,
3895            network_allowlist,
3896            browser_profile_id,
3897        } = request;
3898        // Track A follow-on: exclusive node-local BrowserStationLease after
3899        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3900        // never ImportSession / cookies on C2. Linux/feature-off: probe
3901        // already refused or stub; this block is cfg'd out when feature off.
3902        #[cfg(feature = "dig2-station-probe")]
3903        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3904            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3905            match acquire_lease(
3906                &self.browser_station_leases,
3907                profile_id,
3908                instance_id,
3909                unix_time_ms(),
3910            ) {
3911                Ok(guard) => Some(guard),
3912                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3913                    return Err(failure(
3914                        NodeFailureCode::BrowserStationProfileBusy,
3915                        "dig2browser station profile is already leased by another session",
3916                    ));
3917                }
3918                Err(BrowserStationLeaseError::CapacityExceeded) => {
3919                    return Err(failure(
3920                        NodeFailureCode::BrowserStationProfileBusy,
3921                        "dig2browser station profile lease capacity exhausted",
3922                    ));
3923                }
3924                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3925                    return Err(failure(
3926                        NodeFailureCode::BrowserStationProfileBusy,
3927                        "session already holds a dig2browser station profile lease",
3928                    ));
3929                }
3930            }
3931        } else {
3932            None
3933        };
3934        #[cfg(not(feature = "dig2-station-probe"))]
3935        let _ = browser_profile_id;
3936        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3937            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3938            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3939            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3940            // both read the identical overlay resolved below, one as child
3941            // environment, the other translated into one stdio MCP server
3942            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3943            // only, and nothing downstream reads a harness-MCP overlay for
3944            // that transport yet.
3945            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3946            {
3947                return Err(failure(
3948                    NodeFailureCode::BindingMismatch,
3949                    "harness MCP reservation does not match the exact PTY/ACP provider",
3950                ));
3951            }
3952            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3953            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3954                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3955            })?;
3956            // Every name below is the caller's, carried in the reservation's
3957            // launch description: which environment variables receive the
3958            // endpoint, token and helper program, what the MCP server is called,
3959            // which argv it gets and which stale variables are erased.
3960            let launch = prepared.launch();
3961            let mut env_entries = vec![
3962                (
3963                    OsString::from(launch.endpoint_env.as_str()),
3964                    prepared.endpoint().as_os_str().to_os_string(),
3965                ),
3966                (
3967                    OsString::from(launch.token_env.as_str()),
3968                    prepared.token().expose().into(),
3969                ),
3970                (
3971                    OsString::from(launch.program_env.as_str()),
3972                    prepared.helper_program().as_os_str().to_os_string(),
3973                ),
3974            ];
3975            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3976                env_entries.push(trace_entry);
3977            }
3978            if let Some(door) = crate::door::door_listen_addr() {
3979                env_entries.push((
3980                    OsString::from("HATCHERY_NODE_DOOR"),
3981                    OsString::from(door.to_string()),
3982                ));
3983                // Same key shape as HQ browser/draft ids:
3984                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3985                // slot across node boots when the instance counter resets.
3986                let session_key = format!(
3987                    "pty-{}-{}-{}",
3988                    self.incarnation_id,
3989                    address.workspace_id.as_str(),
3990                    address.session.instance_id.0
3991                );
3992                env_entries.push((
3993                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3994                    OsString::from(session_key),
3995                ));
3996            }
3997            let overlay = NativeMcpServerLaunchOverlay::new(
3998                provider.clone(),
3999                launch.server_name.as_str(),
4000                prepared.helper_program().as_os_str().to_os_string(),
4001                launch.args.iter().map(OsString::from).collect(),
4002                env_entries,
4003                launch.scrub_env.iter().map(OsString::from).collect(),
4004            ).map_err(|_| failure(
4005                NodeFailureCode::HarnessMcpUnavailable,
4006                "harness MCP launch overlay is unavailable",
4007            ))?;
4008            control
4009                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4010                .map_err(|_| failure(
4011                    NodeFailureCode::HarnessMcpUnavailable,
4012                    "harness MCP launch overlay could not be installed",
4013                ))?;
4014            // State-change line: the overlay is installed and will be read
4015            // back by the PTY child's environment or, for ACP,
4016            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4017            // time -- no secrets here, only which instance/provider/mode
4018            // now carries a harness-MCP door.
4019            tracing::info!(
4020                node_id = %self.node_id,
4021                instance_id = ?instance_id,
4022                provider = %provider,
4023                mode = ?mode,
4024                "harness MCP overlay installed for spawn",
4025            );
4026            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4027        } else {
4028            None
4029        };
4030        let prepared_materialization = self.prepare_session_materialization(
4031            &address,
4032            &provider,
4033            mode,
4034            environment_profile,
4035            bundle,
4036            context,
4037            managed_authority.cloned(),
4038        )?;
4039        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4040            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4041            None => (None, None),
4042        };
4043        let materialization_id = materialization_guard
4044            .as_ref()
4045            .and_then(SessionMaterializationGuard::id)
4046            .cloned();
4047        let mut environment_selection = SpawnPreparer::select_environment(self, 
4048            instance_id,
4049            &provider,
4050            mode,
4051            environment_profile,
4052        )?;
4053        // Station network overlays: Codex `-c network_access` and/or Claude
4054        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4055        let provider_home = materialization_guard
4056            .as_ref()
4057            .and_then(SessionMaterializationGuard::provider_home);
4058        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4059            &provider,
4060            mode,
4061            approval_level,
4062            network_allowlist,
4063            provider_home,
4064        )?;
4065        let mut network_overlay_args = network_overlay_args;
4066        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4067        {
4068            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4069            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4070            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4071                homes.extend(std::env::split_paths(&extra));
4072            }
4073            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4074            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4075            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4076        }
4077        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4078            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4079        }
4080        // Session registry: journal the session so it is resumed after a node restart.
4081        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4082            if let Some(file) = self.session_resume_journal() {
4083                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4084                    instance_id: instance_id.0,
4085                    provider: provider.as_str().to_owned(),
4086                    workspace_id: address.workspace_id.as_str().to_owned(),
4087                    provider_session_id: String::new(),
4088                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4089                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4090                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4091                    cwd: String::new(),
4092                    acp: mode == SessionMode::Acp,
4093                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4094                    cols: 0,
4095                    rows: 0,
4096                });
4097                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4098            }
4099        }
4100        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4101            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4102        }
4103        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4104            (prepared, args) if args.is_empty() => prepared,
4105            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4106                overlay.append_extra_args(args).map_err(|_| {
4107                    failure(
4108                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4109                        "network allowlist overlay could not merge with instance launch args",
4110                    )
4111                })?;
4112                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4113            }
4114            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4115                Some(PreparedNativeLaunchOverlay::Instance(
4116                    overlay
4117                        .into_instance_with_extra_args(args)
4118                        .map_err(|_| {
4119                            failure(
4120                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4121                                "network allowlist overlay could not merge with environment overlay",
4122                            )
4123                        })?,
4124                ))
4125            }
4126            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4127                NativeInstanceLaunchOverlay::new(
4128                    provider.clone(),
4129                    match mode {
4130                        SessionMode::Pty => TransportKind::Pty,
4131                        SessionMode::Acp => TransportKind::Acp,
4132                        SessionMode::Inline => TransportKind::Pipe,
4133                    },
4134                    Vec::new(),
4135                    args,
4136                )
4137                .map_err(|_| {
4138                    failure(
4139                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140                        "network allowlist overlay is invalid for this transport",
4141                    )
4142                })?,
4143            )),
4144        };
4145        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4146        // overlay or appended to the overlay the spawn already carries.
4147        #[cfg(unix)]
4148        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4149        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4150            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4151        });
4152        #[cfg(unix)]
4153        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4154        // signer handle is for sessions without one (operator shells).
4155        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4156        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4157            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4158                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4159                let uid = unsafe { libc_geteuid() };
4160                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4161                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4162                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4163                Some(vec![
4164                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4165                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4166                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4167                ])
4168            }
4169            _ => None,
4170        };
4171        #[cfg(not(unix))]
4172        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4173        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4174            (SessionMode::Pty, _, _, Some(identity)) => {
4175                let merged = match environment_overlay {
4176                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4177                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4178                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4179                    }
4180                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4181                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4182                    }
4183                }
4184                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4185                Some(merged)
4186            }
4187            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4188                let session_key = self.m4a_session_key(instance_id);
4189                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4190            }
4191            (SessionMode::Pty, Some(_), None, _) => {
4192                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4193                environment_overlay
4194            }
4195            _ => environment_overlay,
4196        };
4197        let mut instance_overlay = environment_overlay
4198            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4199            .transpose()?
4200            .flatten();
4201        Ok(PreparedSpawn {
4202            instance_overlay,
4203            environment_selection,
4204            materialization_guard,
4205            harness_mcp_overlay,
4206            #[cfg(feature = "dig2-station-probe")]
4207            browser_station_lease_guard,
4208            materialization_id,
4209        })
4210    }
4211}
4212
4213struct SessionRecords {
4214    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4215}
4216
4217struct SpawnIdempotencyCache {
4218    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4219}
4220
4221struct SpawnIdempotencyEntry {
4222    value: SpawnIdempotencyValue,
4223    expires_at: Instant,
4224}
4225
4226enum SpawnIdempotencyValue {
4227    Standard {
4228        spec: SpawnSpec,
4229        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4230    },
4231    Managed {
4232        request: ManagedWorktreeSpawnRequest,
4233        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4234    },
4235    HarnessMcp,
4236}
4237
4238enum DurableManagedSpawnReplayDecision {
4239    Reserved,
4240    PendingLinked,
4241    Committed(ManagedWorktreeSpawnReceipt),
4242}
4243
4244impl SpawnIdempotencyValue {
4245    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4246        match self {
4247            Self::Standard { result, .. } => result
4248                .as_ref()
4249                .ok()
4250                .and_then(|receipt| receipt.context.as_ref()),
4251            Self::Managed { result, .. } => result
4252                .as_ref()
4253                .ok()
4254                .and_then(|receipt| receipt.spawn.context.as_ref()),
4255            Self::HarnessMcp => None,
4256        }
4257        .is_some_and(|receipt| &receipt.id == context_id)
4258    }
4259}
4260
4261#[derive(Clone, Copy)]
4262struct ControllerLease {
4263    connection_id: u64,
4264    expires_at: Instant,
4265}
4266
4267struct NodeEventHistory {
4268    last_sequence: u64,
4269    replay_floor_sequence: u64,
4270    events: VecDeque<NodeEventEnvelope>,
4271    record_providers: BTreeMap<SessionRecordId, AgentId>,
4272    removed_record_providers: BTreeMap<u64, AgentId>,
4273}
4274
4275impl NodeEventHistory {
4276    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4277        Self {
4278            last_sequence: 0,
4279            replay_floor_sequence: 1,
4280            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4281            record_providers,
4282            removed_record_providers: BTreeMap::new(),
4283        }
4284    }
4285}
4286
4287impl NodeShared {
4288    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4289    /// as a descendant of this session's process (with the session's own environment) and
4290    /// never attaches to an operator's shared leader. Same grok home, same login.
4291    fn session_resume_journal(&self) -> Option<PathBuf> {
4292        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4293    }
4294
4295    /// Node start: resume every journalled session through its provider's resume path,
4296    /// under its old instance number, so HQ shows the same chat.
4297    async fn restore_journalled_sessions(&self) {
4298        let Some(file) = self.session_resume_journal() else { return };
4299        if !crate::pty_resume::enabled() { return; }
4300        for e in crate::pty_resume::load(&file) {
4301            crate::pty_resume::remove(&file, e.instance_id);
4302            // Same m4a session key as before the restart: the session keeps its account and nick.
4303            if !e.session_key.is_empty() {
4304                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4305            }
4306            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4307            if e.acp {
4308                let record_id = {
4309                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4310                    records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4311                };
4312                let cur = self.next_instance_id.load(Ordering::Acquire);
4313                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4314                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4315                let resumed = match record_id {
4316                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4317                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4318                };
4319                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4320                let r = match resumed {
4321                    Ok(a) => Ok(a),
4322                    Err(err) => {
4323                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4324                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4325                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4326                    }
4327                };
4328                match r {
4329                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4330                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4331                }
4332                continue;
4333            }
4334            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4335                // Nothing said yet in that chat: start it fresh under the same number.
4336                let cur = self.next_instance_id.load(Ordering::Acquire);
4337                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4340                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4341                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4342                }
4343                continue;
4344            }
4345            let folder = std::path::Path::new(&e.cwd);
4346            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4347            let name = match (in_session_dir, folder.file_name()) {
4348                (true, Some(n)) => n.to_string_lossy().into_owned(),
4349                _ => format!("{} #{}", e.provider, e.instance_id),
4350            };
4351            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4352            let record = match self.index_provider_session(ws, provider, identity, name) {
4353                Ok(r) => r,
4354                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4355            };
4356            let cur = self.next_instance_id.load(Ordering::Acquire);
4357            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4358            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4359            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4360            match r {
4361                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4362                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4363            }
4364        }
4365    }
4366
4367    fn refresh_session_journal(&self) {
4368        let Some(file) = self.session_resume_journal() else { return };
4369        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4370        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4371        crate::pty_resume::refresh(&file, &homes);
4372        let ids: Vec<(u64, String)> = {
4373            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4374            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4375        };
4376        for (inst, sid) in ids {
4377            crate::pty_resume::set_session_id(&file, inst, &sid);
4378        }
4379    }
4380
4381    fn session_grok_leader_socket(
4382        &self,
4383        provider: &AgentId,
4384        mode: SessionMode,
4385        instance_id: AgentInstanceId,
4386    ) -> Option<std::path::PathBuf> {
4387        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4388            return None;
4389        }
4390        let root = self
4391            .m4a_accounts
4392            .as_ref()
4393            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4394            .unwrap_or_else(std::env::temp_dir);
4395        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4396        if let Some(previous) = self
4397            .grok_leaders
4398            .lock()
4399            .unwrap_or_else(|p| p.into_inner())
4400            .insert(instance_id, socket.clone())
4401        {
4402            crate::grok_leader::retire(&previous);
4403        }
4404        Some(socket)
4405    }
4406
4407    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4408    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4409        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4410        if let Some(socket) = socket {
4411            crate::grok_leader::retire(&socket);
4412        }
4413    }
4414
4415    fn new_with_incarnation(
4416        handle: Gate4AgentHandle,
4417        access_token: String,
4418        node_id: NodeId,
4419        incarnation_id: NodeIncarnationId,
4420        workspaces: Vec<WorkspaceConfig>,
4421        enabled_providers: Vec<AgentId>,
4422        provider_runtime_statuses: ProviderRuntimeStatuses,
4423        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4424        provider_contracts: Vec<ProviderContractSupport>,
4425        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4426        spawn_profiles: SpawnProfileRegistry,
4427        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4428        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4429        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4430        state_path: Option<PathBuf>,
4431        records: Vec<ManagedSessionRecord>,
4432        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4433        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4434        materializations: Vec<MaterializationOwnershipRecord>,
4435        persistence_warning: Option<String>,
4436        input_settle_timeout_ms: u64,
4437    ) -> Self {
4438        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4439        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4440        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4441        let (harness_mcp_proxy_event_tx, _) =
4442            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4443        let record_providers = records
4444            .iter()
4445            .map(|record| (record.record_id.clone(), record.provider.clone()))
4446            .collect();
4447        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4448            .iter()
4449            .map(|workspace| {
4450                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4451            })
4452            .collect();
4453        for lease in &managed_worktrees {
4454            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4455        }
4456        let managed_worktree_profiles = workspaces.iter()
4457            .map(|workspace| (
4458                workspace.workspace_id.clone(),
4459                workspace.managed_worktree_profiles.clone(),
4460            ))
4461            .collect();
4462        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4463            managed_worktrees,
4464            managed_worktree_tombstones,
4465        ).expect("durable managed worktree registry was validated while loading");
4466        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4467        managed_worktrees.reattach_record_holders(
4468            &managed_worktree_record_holders(&records),
4469            unix_time_ms(),
4470        );
4471        Self {
4472            handle,
4473            access_token,
4474            node_id,
4475            incarnation_id,
4476            started_at_unix_ms: std::time::SystemTime::now()
4477                .duration_since(std::time::UNIX_EPOCH)
4478                .unwrap_or_default()
4479                .as_millis()
4480                .min(u64::MAX as u128) as u64,
4481            workspaces: RwLock::new(
4482                workspaces
4483                    .into_iter()
4484                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4485                    .collect(),
4486            ),
4487            worktree_service_modes,
4488            managed_worktree_profiles,
4489            managed_worktrees: Mutex::new(managed_worktrees),
4490            enabled_providers,
4491            provider_runtime_statuses,
4492            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4493            provider_runtime_monitor,
4494            provider_contracts,
4495            provider_adapter_contracts,
4496            spawn_profiles,
4497            environment_profiles: RwLock::new(BTreeMap::new()),
4498            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4499            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4500            #[cfg(feature = "dig2-station-probe")]
4501            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4502            bundle_catalog: RwLock::new(BundleCatalog::default()),
4503            delivery_store: Mutex::new(None),
4504            context_pack_store: Mutex::new(None),
4505            context_catalog: RwLock::new(ContextPackCatalog::default()),
4506            native_launch_profile_control,
4507            harness_mcp_registry: None,
4508            native_session_catalog,
4509            session_environment_materializer,
4510            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4511            m4a_live: Mutex::new(BTreeMap::new()),
4512            m4a_keys: Mutex::new(BTreeMap::new()),
4513            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4514            grok_leaders: Mutex::new(BTreeMap::new()),
4515            #[cfg(unix)]
4516            signer_proxy: std::sync::OnceLock::new(),
4517            materializations: Mutex::new(
4518                materializations
4519                    .into_iter()
4520                    .map(|record| (record.id().clone(), record))
4521                    .collect(),
4522            ),
4523            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4524                entries: BTreeMap::new(),
4525            }),
4526            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4527            controller: Mutex::new(None),
4528            provisional_spawns: Mutex::new(BTreeMap::new()),
4529            pending_acp_options: Mutex::new(BTreeMap::new()),
4530            history: Mutex::new(NodeEventHistory::new(record_providers)),
4531            event_tx,
4532            terminal_event_tx,
4533            agent_stream_event_tx,
4534            harness_mcp_proxy_event_tx,
4535            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4536            pty_input_wake: tokio::sync::Notify::new(),
4537            pty_input_marks: Mutex::new(BTreeMap::new()),
4538            next_connection_id: AtomicU64::new(1),
4539            next_instance_id: AtomicU64::new(1),
4540            next_command_id: AtomicU64::new(1),
4541            shutdown: AtomicBool::new(false),
4542            shutdown_notify: Notify::new(),
4543            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4544            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4545            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4546            mutation_gate: AsyncMutex::new(()),
4547            session_bindings: Mutex::new(BTreeMap::new()),
4548            session_records: Mutex::new(SessionRecords {
4549                records: records
4550                    .into_iter()
4551                    .map(|record| (record.record_id.clone(), record))
4552                    .collect(),
4553            }),
4554            state_path,
4555            persistence_error: RwLock::new(
4556                persistence_warning
4557                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4558            ),
4559            state_transaction: Mutex::new(()),
4560            input_settle_timeout_ms,
4561            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4562            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4563            shell_efficiency_profile: OnceLock::new(),
4564            native_session_gauge: AtomicUsize::new(0),
4565            connection_loop_iterations: AtomicU64::new(0),
4566            connection_events_sent: AtomicU64::new(0),
4567            rejected_commands_total: AtomicU64::new(0),
4568            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4569            drive_loop_iterations_total: AtomicU64::new(0),
4570            drive_loop_iterations_idle: AtomicU64::new(0),
4571            session_record_retention: SessionRecordRetentionConfig::default(),
4572            retired_records_total: AtomicU64::new(0),
4573            #[cfg(feature = "fixture")]
4574            fixture_semantic_hook_policy: false,
4575            #[cfg(feature = "fixture")]
4576            fixture_spawn_managed_worktree_v2_failure_probe:
4577                SpawnManagedWorktreeV2FailureProbe::default(),
4578        }
4579    }
4580
4581    #[cfg(test)]
4582    fn new(
4583        handle: Gate4AgentHandle,
4584        access_token: String,
4585        node_id: NodeId,
4586        workspaces: Vec<WorkspaceConfig>,
4587        enabled_providers: Vec<AgentId>,
4588    ) -> Self {
4589        Self::new_with_incarnation(
4590            handle,
4591            access_token,
4592            node_id,
4593            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4594            workspaces,
4595            enabled_providers,
4596            ProviderRuntimeStatuses::default(),
4597            None,
4598            Vec::new(),
4599            Vec::new(),
4600            SpawnProfileRegistry::default(),
4601            None,
4602            None,
4603            None,
4604            None,
4605            Vec::new(),
4606            Vec::new(),
4607            Vec::new(),
4608            Vec::new(),
4609            None,
4610            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4611        )
4612    }
4613
4614    fn persistence_error(&self) -> Option<String> {
4615        self.persistence_error
4616            .read()
4617            .unwrap_or_else(|poisoned| poisoned.into_inner())
4618            .clone()
4619    }
4620
4621    async fn admit_provider_runtime(
4622        &self,
4623        provider: &AgentId,
4624        requirement: ProviderRuntimeRequirement,
4625    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4626        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4627            let pipe_transport = monitor.supports_pipe_transport(provider);
4628            let acp_transport = monitor.supports_acp_transport(provider);
4629            let provider = provider.clone();
4630            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4631            let (status, admission) = timeout(
4632                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4633                refresh,
4634            )
4635            .await
4636            .map_err(|_| failure(
4637                NodeFailureCode::BackendBusy,
4638                "provider runtime probe exceeded its bounded deadline",
4639            ))?
4640            .map_err(|_| failure(
4641                NodeFailureCode::BackendOperationFailed,
4642                "provider runtime probe task failed",
4643            ))?;
4644            if let Some(status) = status {
4645                self
4646                    .provider_runtime_status_updates
4647                    .write()
4648                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4649                    .insert(status.provider().clone(), status);
4650            }
4651            admission.and_then(|policy| {
4652                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4653            })
4654        } else {
4655            // No live monitor means no catalog to consult -- there is
4656            // nothing here to confirm this provider declares a Pipe or ACP
4657            // transport, so Inline/Acp are rejected the same conservative
4658            // way an undeclared provider always was, rather than admitted
4659            // on trust.
4660            crate::provider_runtime::admit_status(
4661                &self.provider_runtime_statuses,
4662                provider,
4663                false,
4664                false,
4665                requirement,
4666            )
4667        };
4668        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4669        admission.map_err(|error| match error {
4670            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4671                NodeFailureCode::BackendOperationFailed,
4672                "provider launcher is unavailable",
4673            ),
4674            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4675                NodeFailureCode::UnsupportedCapability,
4676                "provider semantic capability is not verified",
4677            ),
4678            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4679                NodeFailureCode::BackendBusy,
4680                "provider runtime probe is already in progress",
4681            ),
4682        })
4683    }
4684
4685    /// Whether the catalog declares a Pipe transport for `provider` --
4686    /// the fact `require_policy` needs to admit or reject
4687    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4688    /// catalog to consult, so this stays conservative and rejects, matching
4689    /// `admit_provider_runtime`'s own no-monitor branch.
4690    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4691        match self.provider_runtime_monitor.as_deref() {
4692            Some(monitor) => monitor.supports_pipe_transport(provider),
4693            None => false,
4694        }
4695    }
4696
4697    /// Whether the catalog declares an ACP transport for `provider` -- the
4698    /// fact `require_policy` needs to admit or reject
4699    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4700    /// `provider_supports_pipe_transport` exactly, including the
4701    /// conservative no-monitor default.
4702    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4703        match self.provider_runtime_monitor.as_deref() {
4704            Some(monitor) => monitor.supports_acp_transport(provider),
4705            None => false,
4706        }
4707    }
4708
4709    /// Grants hook ingestion to a provider whose catalog entry declares a
4710    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4711    ///
4712    /// `admit_status`'s `RawPassthrough` arm hands back
4713    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4714    /// correct for everything it was written to answer, and wrong for hooks
4715    /// specifically. A hook event is not inferred from terminal behaviour,
4716    /// so it has nothing to do with whether a launcher probe verified this
4717    /// CLI version's PTY contract; the authority is the catalog declaring
4718    /// the adapter plus the ingress authenticating the route. Without this
4719    /// the node installs a provider's hooks, the CLI faithfully calls them,
4720    /// and every event is refused -- which is exactly the state grok was in.
4721    ///
4722    /// Applied to both admission paths so the monitor's own derivation and
4723    /// this coarse fallback cannot disagree about the same provider.
4724    fn admit_declared_hook_semantics(
4725        &self,
4726        provider: &AgentId,
4727        policy: ProviderRuntimePolicy,
4728    ) -> ProviderRuntimePolicy {
4729        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4730            return policy;
4731        }
4732        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4733            contract.provider == *provider && contract.family == AdapterFamily::Hook
4734        });
4735        if !declares_hook {
4736            return policy;
4737        }
4738        ProviderRuntimePolicy::new(
4739            policy.raw_pty_lifecycle,
4740            policy.semantic_readiness,
4741            policy.structured_prompt,
4742            policy.provider_session_identity,
4743            policy.semantic_resume,
4744            true,
4745        )
4746        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4747    }
4748
4749    /// Station network allowlist → provider launch overlay argv.
4750    ///
4751    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4752    ///   `provider_native` under Moderate (PTY or ACP).
4753    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4754    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4755    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4756    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4757    ///   no overlay.
4758    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4759    ///   empty overlay when no `provider_native`; invented native keys already
4760    ///   refuse at load / resolve.
4761    ///
4762    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4763    fn resolve_network_allowlist_overlay_args(
4764        &self,
4765        provider: &AgentId,
4766        mode: SessionMode,
4767        approval_level: ApprovalLevel,
4768        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4769        provider_home: Option<&Path>,
4770    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4771        let Some(allowlist_id) = network_allowlist else {
4772            return Ok(Vec::new());
4773        };
4774        let catalog = self
4775            .network_allowlist_catalog
4776            .read()
4777            .unwrap_or_else(|poisoned| poisoned.into_inner());
4778        let Some(entry) = catalog.get(allowlist_id) else {
4779            return Err(failure(
4780                NodeFailureCode::UnknownNetworkAllowlist,
4781                "spawn network allowlist is unavailable on this node",
4782            ));
4783        };
4784
4785        // Claude: settings-shaped overlay from permits (not provider_native).
4786        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4787            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4788                |message| {
4789                    failure(
4790                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4791                        &message,
4792                    )
4793                },
4794            )?;
4795            if !matches!(mode, SessionMode::Pty) {
4796                return Err(failure(
4797                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4798                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4799                ));
4800            }
4801            let Some(home) = provider_home else {
4802                return Err(failure(
4803                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4804                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4805                ));
4806            };
4807            let domains =
4808                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4809            if domains.is_empty() {
4810                return Err(failure(
4811                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4813                ));
4814            }
4815            let settings_path = home.join(
4816                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4817            );
4818            let json =
4819                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4820            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4821                |_| {
4822                    failure(
4823                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4824                        "Claude station network settings sidecar could not be written under ProviderHome",
4825                    )
4826                },
4827            )?;
4828            let overlay =
4829                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4830            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4831        }
4832
4833        let Some(native) = entry.provider_native.as_ref() else {
4834            return Ok(Vec::new());
4835        };
4836        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4837            provider.as_str(),
4838            approval_level,
4839            native,
4840        )
4841        .map_err(|message| {
4842            failure(
4843                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4844                &message,
4845            )
4846        })?;
4847        if overlay.is_empty() {
4848            return Ok(Vec::new());
4849        }
4850        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4851            return Err(failure(
4852                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853                "Codex network_access -c overlay requires PTY or ACP transport",
4854            ));
4855        }
4856        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4857    }
4858
4859    /// Backward-compatible alias used by Codex-focused unit tests.
4860    fn resolve_codex_network_access_overlay_args(
4861        &self,
4862        provider: &AgentId,
4863        mode: SessionMode,
4864        approval_level: ApprovalLevel,
4865        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4866    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4867        self.resolve_network_allowlist_overlay_args(
4868            provider,
4869            mode,
4870            approval_level,
4871            network_allowlist,
4872            None,
4873        )
4874    }
4875
4876    fn resolve_environment_profile(
4877        &self,
4878        resolved: &ResolvedSpawnSpec,
4879    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4880        // Station network allowlist catalog enforce (plan
4881        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4882        // catalog — unknown ids refuse rather than silent ambient. Runs even
4883        // when no environment_profile_id is present so a bare allowlist still
4884        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4885        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4886            let catalog = self
4887                .network_allowlist_catalog
4888                .read()
4889                .unwrap_or_else(|poisoned| poisoned.into_inner());
4890            let Some(entry) = catalog.get(allowlist_id) else {
4891                return Err(failure(
4892                    NodeFailureCode::UnknownNetworkAllowlist,
4893                    "spawn network allowlist is unavailable on this node",
4894                ));
4895            };
4896            // Track B: refuse provider-native mappings this provider cannot
4897            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4898            if let Some(native) = entry.provider_native.as_ref() {
4899                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4900                    resolved.provider.as_str(),
4901                    resolved.approval_level,
4902                    native,
4903                ) {
4904                    return Err(failure(
4905                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906                        &message,
4907                    ));
4908                }
4909            }
4910        }
4911        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4912        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4913        // cheap named-pipe path reachability must succeed (Windows
4914        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4915        // NodeFailureCode. Runs even without environment_profile_id (same
4916        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4917        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4918        // acquired later in spawn_session_with_deadline (after instance id).
4919        #[cfg(feature = "dig2-station-probe")]
4920        if resolved.browser_profile_id.is_some() {
4921            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4922            match probe_station_reachable() {
4923                Ok(()) => {}
4924                Err(StationProbeError::UnsupportedPlatform)
4925                | Err(StationProbeError::InvalidPipeSuffix) => {
4926                    return Err(failure(
4927                        NodeFailureCode::BrowserStationProbeUnavailable,
4928                        "dig2browser station probe unavailable on this platform",
4929                    ));
4930                }
4931                Err(StationProbeError::Unreachable) => {
4932                    return Err(failure(
4933                        NodeFailureCode::BrowserStationUnreachable,
4934                        "dig2browser station named-pipe is unreachable",
4935                    ));
4936                }
4937            }
4938        }
4939        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4940            return Ok(None);
4941        };
4942        let profiles = self
4943            .environment_profiles
4944            .read()
4945            .unwrap_or_else(|poisoned| poisoned.into_inner());
4946        let binding = profiles.get(profile_id).ok_or_else(|| {
4947            failure(
4948                NodeFailureCode::UnknownEnvironmentProfile,
4949                "spawn environment profile is unavailable on this node",
4950            )
4951        })?;
4952        if binding.provider != resolved.provider
4953            || binding.native_profile_id(resolved.mode).is_none()
4954        {
4955            return Err(failure(
4956                NodeFailureCode::EnvironmentProfileBindingMismatch,
4957                "spawn environment profile does not match the provider and mode",
4958            ));
4959        }
4960        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4961        // after catalog + optional dig2 probe above. Empty/whitespace ids
4962        // already refuse at Spawn*Id construction.
4963        Ok(Some(ResolvedEnvironmentProfileReceipt {
4964            profile_id: binding.id.clone(),
4965            profile_revision: binding.revision.clone(),
4966            network_allowlist: resolved.network_allowlist.clone(),
4967            browser_profile_id: resolved.browser_profile_id.clone(),
4968        }))
4969    }
4970
4971    fn resolve_bundle(
4972        &self,
4973        resolved: &ResolvedSpawnSpec,
4974        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4975    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4976        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4977            return Ok(None);
4978        };
4979        let catalog = self.bundle_catalog
4980            .read()
4981            .unwrap_or_else(|poisoned| poisoned.into_inner());
4982        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4983                failure(
4984                    NodeFailureCode::UnknownBundle,
4985                    "spawn bundle is unavailable on this node",
4986                )
4987            })?;
4988        self.bundle_layout(
4989            &resolved.provider,
4990            resolved.mode,
4991            environment_profile,
4992            bundle,
4993        )?;
4994        Ok(Some(bundle.receipt()))
4995    }
4996
4997    fn begin_delivery_stage(
4998        &self,
4999        manifest: DeliveryBundleManifestV2,
5000    ) -> Result<NodeResponse, NodeFailure> {
5001        let mut store = self
5002            .delivery_store
5003            .lock()
5004            .unwrap_or_else(|poisoned| poisoned.into_inner());
5005        let store = store.as_mut().ok_or_else(|| {
5006            failure(
5007                NodeFailureCode::DeliveryStageStorageFailed,
5008                "node delivery store is unavailable",
5009            )
5010        })?;
5011        let begun = store.begin(manifest).map_err(delivery_failure)?;
5012        Ok(NodeResponse::DeliveryStageBegun {
5013            stage_id: begun.stage_id,
5014            manifest_digest: begun.manifest_digest,
5015            missing_blobs: begun.missing_blobs,
5016        })
5017    }
5018
5019    fn put_delivery_blob_chunk(
5020        &self,
5021        stage_id: DeliveryStageId,
5022        blob_digest: DeliveryBlobDigestV1,
5023        offset: u64,
5024        chunk_hex: DeliveryBlobChunkHexV1,
5025    ) -> Result<NodeResponse, NodeFailure> {
5026        let mut store = self
5027            .delivery_store
5028            .lock()
5029            .unwrap_or_else(|poisoned| poisoned.into_inner());
5030        let store = store.as_mut().ok_or_else(|| {
5031            failure(
5032                NodeFailureCode::DeliveryStageStorageFailed,
5033                "node delivery store is unavailable",
5034            )
5035        })?;
5036        let accepted = store
5037            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5038            .map_err(delivery_failure)?;
5039        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5040            stage_id: accepted.stage_id,
5041            blob_digest: accepted.blob_digest,
5042            next_offset: accepted.next_offset,
5043        })
5044    }
5045
5046    fn commit_delivery_stage(
5047        &self,
5048        stage_id: DeliveryStageId,
5049    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5050        let mut store = self
5051            .delivery_store
5052            .lock()
5053            .unwrap_or_else(|poisoned| poisoned.into_inner());
5054        let store = store.as_mut().ok_or_else(|| {
5055            failure(
5056                NodeFailureCode::DeliveryStageStorageFailed,
5057                "node delivery store is unavailable",
5058            )
5059        })?;
5060        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5061        let mut catalog = self
5062            .bundle_catalog
5063            .write()
5064            .unwrap_or_else(|poisoned| poisoned.into_inner());
5065        let mut next_catalog = catalog.clone();
5066        next_catalog
5067            .insert_idempotent(prepared.bundle().clone())
5068            .map_err(|_| {
5069                failure(
5070                    NodeFailureCode::DeliveryStageConflict,
5071                    "delivery bundle identity conflicts with installed content",
5072                )
5073            })?;
5074        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5075        *catalog = next_catalog;
5076        Ok(receipt)
5077    }
5078
5079    fn abort_delivery_stage(
5080        &self,
5081        stage_id: &DeliveryStageId,
5082    ) -> Result<(), NodeFailure> {
5083        let mut store = self
5084            .delivery_store
5085            .lock()
5086            .unwrap_or_else(|poisoned| poisoned.into_inner());
5087        let store = store.as_mut().ok_or_else(|| {
5088            failure(
5089                NodeFailureCode::DeliveryStageStorageFailed,
5090                "node delivery store is unavailable",
5091            )
5092        })?;
5093        store.abort(stage_id).map_err(delivery_failure)
5094    }
5095
5096    fn resolve_context(
5097        &self,
5098        resolved: &ResolvedSpawnSpec,
5099    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5100        let Some(context_id) = resolved.context_id.as_ref() else {
5101            return Ok(None);
5102        };
5103        self.context_catalog
5104            .read()
5105            .unwrap_or_else(|poisoned| poisoned.into_inner())
5106            .get(context_id)
5107            .map(|context| context.receipt().clone())
5108            .ok_or_else(|| {
5109                failure(
5110                    NodeFailureCode::UnknownContextPack,
5111                    "spawn context pack is unavailable on this node",
5112                )
5113            })
5114            .map(Some)
5115    }
5116
5117    fn bundle_layout(
5118        &self,
5119        provider: &AgentId,
5120        mode: SessionMode,
5121        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5122        bundle: &NodeBundle,
5123    ) -> Result<BundleProviderLayout, NodeFailure> {
5124        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5125            failure(
5126                NodeFailureCode::BundleBindingMismatch,
5127                "spawn bundle does not match the provider and mode",
5128            )
5129        })?;
5130        if layout != BundleProviderLayout::Codex {
5131            return Ok(layout);
5132        }
5133        let profile = environment_profile.and_then(|receipt| {
5134            let profiles = self
5135                .environment_profiles
5136                .read()
5137                .unwrap_or_else(|poisoned| poisoned.into_inner());
5138            let binding = profiles.get(&receipt.profile_id)?;
5139            if binding.revision != receipt.profile_revision
5140                || &binding.provider != provider
5141                || binding.native_profile_id(mode).is_none()
5142            {
5143                return None;
5144            }
5145            self.environment_materialization_profiles
5146                .read()
5147                .unwrap_or_else(|poisoned| poisoned.into_inner())
5148                .get(&receipt.profile_id)
5149                .cloned()
5150        });
5151        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5152            return Err(failure(
5153                NodeFailureCode::BundleBindingMismatch,
5154                "Codex bundle requires an exact isolated CODEX_HOME profile",
5155            ));
5156        }
5157        Ok(layout)
5158    }
5159
5160    fn select_environment_profile(
5161        &self,
5162        instance_id: AgentInstanceId,
5163        provider: &AgentId,
5164        mode: SessionMode,
5165        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5166    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5167        let Some(environment_profile) = environment_profile else {
5168            return Ok(None);
5169        };
5170        let native_profile_id = {
5171            let profiles = self
5172                .environment_profiles
5173                .read()
5174                .unwrap_or_else(|poisoned| poisoned.into_inner());
5175            let binding = profiles
5176                .get(&environment_profile.profile_id)
5177                .ok_or_else(|| {
5178                    failure(
5179                        NodeFailureCode::UnknownEnvironmentProfile,
5180                        "session environment profile is unavailable on this node",
5181                    )
5182                })?;
5183            if &binding.revision != &environment_profile.profile_revision
5184                || &binding.provider != provider
5185            {
5186                return Err(failure(
5187                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5188                    "session environment profile revision or provider changed",
5189                ));
5190            }
5191            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5192                failure(
5193                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5194                    "session environment profile does not support this mode",
5195                )
5196            })?
5197        };
5198        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5199            failure(
5200                NodeFailureCode::BackendOperationFailed,
5201                "native environment profile controller is unavailable",
5202            )
5203        })?;
5204        control
5205            .select_native_launch_profile(instance_id, native_profile_id)
5206            .map_err(|_| {
5207                failure(
5208                    NodeFailureCode::BackendOperationFailed,
5209                    "native environment profile selection failed",
5210                )
5211            })?;
5212        Ok(Some(NativeEnvironmentSelectionGuard {
5213            control: Some(control),
5214            instance_id,
5215        }))
5216    }
5217
5218    fn install_prepared_launch_overlay(
5219        &self,
5220        instance_id: AgentInstanceId,
5221        overlay: PreparedNativeLaunchOverlay,
5222    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5223        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224            failure(
5225                NodeFailureCode::BackendOperationFailed,
5226                "native launch profile controller is unavailable",
5227            )
5228        })?;
5229        match overlay {
5230            PreparedNativeLaunchOverlay::Environment(overlay) => {
5231                control
5232                    .install_native_launch_environment_overlay(instance_id, overlay)
5233                    .map_err(|_| failure(
5234                        NodeFailureCode::BackendOperationFailed,
5235                        "session environment overlay installation failed",
5236                    ))?;
5237                Ok(None)
5238            }
5239            PreparedNativeLaunchOverlay::Instance(overlay) => {
5240                control
5241                    .install_native_instance_launch_overlay(instance_id, overlay)
5242                    .map_err(|_| failure(
5243                        NodeFailureCode::BundleBindingMismatch,
5244                        "session bundle launch overlay installation failed",
5245                    ))?;
5246                Ok(Some(NativeInstanceOverlayGuard {
5247                    control: Some(control),
5248                    instance_id,
5249                }))
5250            }
5251        }
5252    }
5253
5254    fn environment_materialization_profile(
5255        &self,
5256        environment_profile: &ResolvedEnvironmentProfileReceipt,
5257    ) -> Option<NodeSessionMaterializationProfile> {
5258        self.environment_materialization_profiles
5259            .read()
5260            .unwrap_or_else(|poisoned| poisoned.into_inner())
5261            .get(&environment_profile.profile_id)
5262            .cloned()
5263    }
5264
5265    fn materialization_profile(
5266        &self,
5267        provider: &AgentId,
5268        mode: SessionMode,
5269        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5270        bundle: Option<&ResolvedBundleReceipt>,
5271        context: Option<&ResolvedContextPackReceipt>,
5272    ) -> Result<Option<(
5273        NodeSessionMaterializationProfile,
5274        bool,
5275        Option<BundleProviderLayout>,
5276    )>, NodeFailure> {
5277        let environment = environment_profile
5278            .and_then(|receipt| self.environment_materialization_profile(receipt));
5279        let (bundle, bundle_layout) = match bundle {
5280            Some(receipt) => {
5281                let catalog = self.bundle_catalog.read()
5282                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5283                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5284                    NodeFailureCode::UnknownBundle,
5285                    "session bundle is unavailable on this node",
5286                ))?;
5287                if installed.receipt() != *receipt {
5288                    return Err(failure(
5289                        NodeFailureCode::BundleBindingMismatch,
5290                        "session bundle revision or digest changed",
5291                    ));
5292                }
5293                let layout = self.bundle_layout(
5294                    provider,
5295                    mode,
5296                    environment_profile,
5297                    installed,
5298                )?;
5299                (Some(installed.clone()), Some(layout))
5300            }
5301            None => (None, None),
5302        };
5303        let has_environment_overlay = environment.is_some() || context.is_some();
5304        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5305            (None, None, None) => None,
5306            (Some(profile), None, None) => Some(Ok(profile)),
5307            (None, Some(bundle), Some(layout)) => {
5308                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5309            }
5310            (Some(profile), Some(bundle), Some(layout)) => {
5311                Some(profile.with_bundle(bundle, layout))
5312            }
5313            _ => {
5314                return Err(failure(
5315                    NodeFailureCode::BundleBindingMismatch,
5316                    "session bundle provider layout is unavailable",
5317                ));
5318            }
5319        };
5320        let profile = profile
5321            .transpose()
5322            .map_err(|_| failure(
5323                NodeFailureCode::BundleMaterializationFailed,
5324                "session bundle materialization profile is invalid",
5325            ))?;
5326        let profile = match (profile, context) {
5327            (Some(profile), Some(receipt)) => {
5328                let catalog = self.context_catalog
5329                    .read()
5330                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5331                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5332                    NodeFailureCode::UnknownContextPack,
5333                    "session context pack is unavailable on this node",
5334                ))?;
5335                if pack.receipt() != receipt {
5336                    return Err(failure(
5337                        NodeFailureCode::ContextPackMaterializationFailed,
5338                        "session context pack identity changed",
5339                    ));
5340                }
5341                profile.with_context(pack)
5342            }
5343            (None, Some(receipt)) => {
5344                let catalog = self.context_catalog
5345                    .read()
5346                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5347                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348                    NodeFailureCode::UnknownContextPack,
5349                    "session context pack is unavailable on this node",
5350                ))?;
5351                if pack.receipt() != receipt {
5352                    return Err(failure(
5353                        NodeFailureCode::ContextPackMaterializationFailed,
5354                        "session context pack identity changed",
5355                    ));
5356                }
5357                NodeSessionMaterializationProfile::from_context(pack)
5358            }
5359            (Some(profile), None) => Ok(profile),
5360            (None, None) => return Ok(None),
5361        }
5362        .map_err(|_| failure(
5363            NodeFailureCode::ContextPackMaterializationFailed,
5364            "session context pack materialization profile is invalid",
5365        ))?;
5366        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5367    }
5368
5369    fn prepare_session_materialization<'a>(
5370        &'a self,
5371        address: &SessionAddress,
5372        provider: &AgentId,
5373        mode: SessionMode,
5374        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5375        bundle: Option<&ResolvedBundleReceipt>,
5376        context: Option<&ResolvedContextPackReceipt>,
5377        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5378    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5379        let Some((profile, has_environment_overlay, bundle_layout)) =
5380            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5381        else {
5382            return Ok(None);
5383        };
5384        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5385            failure(
5386                NodeFailureCode::BackendOperationFailed,
5387                "session environment materializer is unavailable",
5388            )
5389        })?;
5390        let materialization_failure_code = if context.is_some() {
5391            NodeFailureCode::ContextPackMaterializationFailed
5392        } else if bundle.is_some() {
5393            NodeFailureCode::BundleMaterializationFailed
5394        } else {
5395            NodeFailureCode::BackendOperationFailed
5396        };
5397        let id = MaterializationId::new(format!(
5398            "mat-{}-{}-{}",
5399            self.incarnation_id,
5400            address.session.instance_id.0,
5401            address.session.generation.0,
5402        ))
5403        .map_err(|_| {
5404            failure(
5405                NodeFailureCode::BackendOperationFailed,
5406                "session environment materialization identity failed",
5407            )
5408        })?;
5409        let owner = MaterializationOwner::Session {
5410            incarnation_id: self.incarnation_id,
5411            instance_id: address.session.instance_id,
5412            generation: address.session.generation,
5413        };
5414        let mut ownership = materializer
5415            .begin(
5416                id.clone(),
5417                environment_profile.cloned(),
5418                bundle.cloned(),
5419                context.cloned(),
5420                owner,
5421                managed_lease_id,
5422                &profile,
5423                unix_time_ms(),
5424            )
5425            .map_err(|_| {
5426                failure(
5427                    materialization_failure_code,
5428                    "session materialization preparation failed",
5429                )
5430            })?;
5431        {
5432            let _transaction = self
5433                .state_transaction
5434                .lock()
5435                .unwrap_or_else(|poisoned| poisoned.into_inner());
5436            let duplicate = self
5437                .materializations
5438                .lock()
5439                .unwrap_or_else(|poisoned| poisoned.into_inner())
5440                .insert(id.clone(), ownership.clone());
5441            if duplicate.is_some() {
5442                return Err(failure(
5443                    NodeFailureCode::BackendOperationFailed,
5444                    "session environment materialization identity conflict",
5445                ));
5446            }
5447            if let Err(error) = self.persist_state_locked() {
5448                self.materializations
5449                    .lock()
5450                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5451                    .remove(&id);
5452                return Err(persistence_failure(error));
5453            }
5454        }
5455        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5456            Ok(environment) => environment,
5457            Err(_) => {
5458                let transaction = self
5459                    .state_transaction
5460                    .lock()
5461                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5462                self.materializations
5463                    .lock()
5464                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                    .insert(id.clone(), ownership.clone());
5466                if self.persist_state_locked().is_err() {
5467                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5468                }
5469                drop(transaction);
5470                if ownership.state() == MaterializationState::CleanupRequired {
5471                    let _ = self.cleanup_materialization(&id);
5472                }
5473                return Err(failure(
5474                    materialization_failure_code,
5475                    "session materialization failed",
5476                ));
5477            }
5478        };
5479        {
5480            let _transaction = self
5481                .state_transaction
5482                .lock()
5483                .unwrap_or_else(|poisoned| poisoned.into_inner());
5484            self.materializations
5485                .lock()
5486                .unwrap_or_else(|poisoned| poisoned.into_inner())
5487                .insert(id.clone(), ownership.clone());
5488            if let Err(error) = self.persist_state_locked() {
5489                drop(_transaction);
5490                let _ = self.cleanup_materialization(&id);
5491                return Err(persistence_failure(error));
5492            }
5493        }
5494        let guard = SessionMaterializationGuard {
5495            shared: self,
5496            id: Some(id),
5497            provider_home: Some(ownership.provider_home().to_path_buf()),
5498        };
5499        let bundle_arguments = match bundle {
5500            Some(receipt) => {
5501                let catalog = self.bundle_catalog.read()
5502                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5503                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5504                    NodeFailureCode::UnknownBundle,
5505                    "session bundle is unavailable on this node",
5506                ))?;
5507                if installed.receipt() != *receipt {
5508                    return Err(failure(
5509                        NodeFailureCode::BundleBindingMismatch,
5510                        "session bundle revision or digest changed",
5511                    ));
5512                }
5513                let layout = bundle_layout.ok_or_else(|| failure(
5514                    NodeFailureCode::BundleBindingMismatch,
5515                    "materialized bundle provider layout is unavailable",
5516                ))?;
5517                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5518                    failure(
5519                        NodeFailureCode::BundleMaterializationFailed,
5520                        "session bundle byte revalidation failed",
5521                    )
5522                })?;
5523                Some(bundle_launch_arguments(
5524                    layout,
5525                    ownership.bundle_root(),
5526                    ownership.provider_home(),
5527                ).map_err(|_| failure(
5528                    NodeFailureCode::BundleBindingMismatch,
5529                    "session bundle launch binding is invalid",
5530                ))?)
5531            }
5532            None => None,
5533        };
5534        if let Some(receipt) = context {
5535            materializer
5536                .revalidate_context(&ownership, receipt)
5537                .map_err(|_| failure(
5538                    NodeFailureCode::ContextPackMaterializationFailed,
5539                    "session context pack byte revalidation failed",
5540                ))?;
5541        }
5542        let transport = match mode {
5543            SessionMode::Pty => TransportKind::Pty,
5544            SessionMode::Inline => TransportKind::Pipe,
5545            SessionMode::Acp => TransportKind::Acp,
5546        };
5547        let overlay = if let Some(extra_args) = bundle_arguments {
5548            Some(PreparedNativeLaunchOverlay::Instance(
5549                NativeInstanceLaunchOverlay::new(
5550                    provider.clone(),
5551                    transport,
5552                    if has_environment_overlay {
5553                        environment
5554                    } else {
5555                        Vec::new()
5556                    },
5557                    extra_args,
5558                )
5559                .map_err(|_| {
5560                    failure(
5561                        NodeFailureCode::BundleBindingMismatch,
5562                        "session bundle launch overlay is invalid",
5563                    )
5564                })?,
5565            ))
5566        } else if has_environment_overlay {
5567            let context_only = environment_profile.is_none()
5568                && bundle.is_none()
5569                && context.is_some();
5570            let overlay = if context_only {
5571                NativeLaunchEnvironmentOverlay::new_context_root(
5572                    provider.clone(),
5573                    transport,
5574                    environment,
5575                )
5576                .map_err(|_| {
5577                    failure(
5578                        NodeFailureCode::ContextPackMaterializationFailed,
5579                        "context-only launch overlay is invalid",
5580                    )
5581                })?
5582            } else {
5583                NativeLaunchEnvironmentOverlay::new(
5584                    provider.clone(),
5585                    transport,
5586                    environment,
5587                )
5588                .map_err(|_| {
5589                    failure(
5590                        NodeFailureCode::BackendOperationFailed,
5591                        "session environment overlay is invalid",
5592                    )
5593                })?
5594            };
5595            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5596        } else {
5597            None
5598        };
5599        Ok(Some((overlay, guard)))
5600    }
5601
5602    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5603        let Some(mut ownership) = self
5604            .materializations
5605            .lock()
5606            .unwrap_or_else(|poisoned| poisoned.into_inner())
5607            .get(id)
5608            .cloned()
5609        else {
5610            return Ok(());
5611        };
5612        if ownership.state() != MaterializationState::CleanupRequired {
5613            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5614                failure(
5615                    NodeFailureCode::BackendOperationFailed,
5616                    "session environment cleanup state is invalid",
5617                )
5618            })?;
5619        }
5620        {
5621            let _transaction = self
5622                .state_transaction
5623                .lock()
5624                .unwrap_or_else(|poisoned| poisoned.into_inner());
5625            let previous = self
5626                .materializations
5627                .lock()
5628                .unwrap_or_else(|poisoned| poisoned.into_inner())
5629                .insert(id.clone(), ownership.clone());
5630            if let Err(error) = self.persist_state_locked() {
5631                let mut materializations = self
5632                    .materializations
5633                    .lock()
5634                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5635                if let Some(previous) = previous {
5636                    materializations.insert(id.clone(), previous);
5637                }
5638                return Err(persistence_failure(error));
5639            }
5640        }
5641        let cleanup = self
5642            .session_environment_materializer
5643            .as_ref()
5644            .ok_or(())
5645            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5646        if cleanup.is_err() {
5647            let _ = ownership.mark_recovery_required(unix_time_ms());
5648            self.materializations
5649                .lock()
5650                .unwrap_or_else(|poisoned| poisoned.into_inner())
5651                .insert(id.clone(), ownership);
5652            if self.persist_state().is_err() {
5653                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5654            }
5655            return Err(failure(
5656                NodeFailureCode::BackendOperationFailed,
5657                "session environment cleanup requires recovery",
5658            ));
5659        }
5660        let _transaction = self
5661            .state_transaction
5662            .lock()
5663            .unwrap_or_else(|poisoned| poisoned.into_inner());
5664        self.materializations
5665            .lock()
5666            .unwrap_or_else(|poisoned| poisoned.into_inner())
5667            .remove(id);
5668        if let Err(error) = self.persist_state_locked() {
5669            self.materializations
5670                .lock()
5671                .unwrap_or_else(|poisoned| poisoned.into_inner())
5672                .insert(id.clone(), ownership);
5673            return Err(persistence_failure(error));
5674        }
5675        Ok(())
5676    }
5677
5678    fn resolve_record_materialization(
5679        &self,
5680        record_id: &SessionRecordId,
5681        provider: &AgentId,
5682        mode: SessionMode,
5683        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5684        bundle: Option<&ResolvedBundleReceipt>,
5685        context: Option<&ResolvedContextPackReceipt>,
5686    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5687        let ownership = {
5688            let materializations = self
5689                .materializations
5690                .lock()
5691                .unwrap_or_else(|poisoned| poisoned.into_inner());
5692            let mut matches = materializations.values().filter(|record| {
5693                record.owner()
5694                    == &MaterializationOwner::Record {
5695                        record_id: record_id.clone(),
5696                    }
5697            });
5698            let ownership = matches.next().cloned();
5699            if matches.next().is_some() {
5700                return Err(failure(
5701                    NodeFailureCode::BackendOperationFailed,
5702                    "managed session has conflicting environment ownership",
5703                ));
5704            }
5705            ownership
5706        };
5707        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5708            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5709                failure(
5710                    NodeFailureCode::ContextPackMaterializationFailed,
5711                    "session context materializer is unavailable",
5712                )
5713            })?;
5714            let pack = materializer
5715                .revalidate_context(ownership, receipt)
5716                .map_err(|_| {
5717                    self.mark_materialization_recovery_required(ownership.id());
5718                    failure(
5719                        NodeFailureCode::ContextPackMaterializationFailed,
5720                        "managed session context pack revalidation failed",
5721                    )
5722                })?;
5723            self.context_catalog
5724                .write()
5725                .unwrap_or_else(|poisoned| poisoned.into_inner())
5726                .insert(pack)
5727                .map_err(|_| failure(
5728                    NodeFailureCode::ContextPackMaterializationFailed,
5729                    "managed session context pack catalog restore failed",
5730                ))?;
5731        }
5732        let materialization = self.materialization_profile(
5733            provider,
5734            mode,
5735            environment_profile,
5736            bundle,
5737            context,
5738        )?;
5739        match (ownership, materialization) {
5740            (None, None) => Ok(None),
5741            (None, Some(_)) | (Some(_), None) => Err(failure(
5742                NodeFailureCode::BackendOperationFailed,
5743                "managed session materialization is unavailable",
5744            )),
5745            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5746                if ownership.environment_profile() != environment_profile
5747                    || ownership.bundle() != bundle
5748                    || ownership.context() != context
5749                    || ownership.state() != MaterializationState::Ready
5750                {
5751                    self.mark_materialization_recovery_required(ownership.id());
5752                    return Err(failure(
5753                        NodeFailureCode::BackendOperationFailed,
5754                        "managed session environment ownership requires recovery",
5755                    ));
5756                }
5757                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5758                    failure(
5759                        NodeFailureCode::BackendOperationFailed,
5760                        "session environment materializer is unavailable",
5761                    )
5762                })?;
5763                if materializer.revalidate(&ownership).is_err() {
5764                    self.mark_materialization_recovery_required(ownership.id());
5765                    return Err(failure(
5766                        NodeFailureCode::BackendOperationFailed,
5767                        "managed session environment revalidation failed",
5768                    ));
5769                }
5770                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5771                    let catalog = self.bundle_catalog.read()
5772                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5773                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5774                        return Err(failure(
5775                            NodeFailureCode::UnknownBundle,
5776                            "managed session bundle is unavailable",
5777                        ));
5778                    };
5779                    let layout = bundle_layout.ok_or_else(|| failure(
5780                        NodeFailureCode::BundleBindingMismatch,
5781                        "record bundle provider layout is unavailable",
5782                    ))?;
5783                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5784                        drop(catalog);
5785                        self.mark_materialization_recovery_required(ownership.id());
5786                        return Err(failure(
5787                            NodeFailureCode::BundleMaterializationFailed,
5788                            "managed session bundle revalidation failed",
5789                        ));
5790                    }
5791                    Some(bundle_launch_arguments(
5792                        layout,
5793                        ownership.bundle_root(),
5794                        ownership.provider_home(),
5795                    ).map_err(|_| failure(
5796                        NodeFailureCode::BundleBindingMismatch,
5797                        "managed session bundle launch binding is invalid",
5798                    ))?)
5799                } else {
5800                    None
5801                };
5802                if !has_environment_materialization {
5803                    let overlay = bundle_arguments.map(|extra_args| {
5804                        NativeInstanceLaunchOverlay::new(
5805                            provider.clone(),
5806                            TransportKind::Pty,
5807                            Vec::new(),
5808                            extra_args,
5809                        )
5810                        .map(PreparedNativeLaunchOverlay::Instance)
5811                        .map_err(|_| failure(
5812                            NodeFailureCode::BundleBindingMismatch,
5813                            "managed session bundle launch overlay is invalid",
5814                        ))
5815                    }).transpose()?;
5816                    return Ok(Some((ownership.id().clone(), overlay)));
5817                }
5818                let environment = match materializer.resolve_environment(&ownership, &profile) {
5819                    Ok(environment) => environment,
5820                    Err(
5821                        SessionEnvironmentMaterializeError::SecretUnavailable
5822                        | SessionEnvironmentMaterializeError::SecretDenied
5823                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5824                    ) => {
5825                        return Err(failure(
5826                            NodeFailureCode::BackendOperationFailed,
5827                            "managed session environment resolution failed",
5828                        ));
5829                    }
5830                    Err(
5831                        SessionEnvironmentMaterializeError::InvalidRoot
5832                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5833                        | SessionEnvironmentMaterializeError::Filesystem(_)
5834                        | SessionEnvironmentMaterializeError::Record(_),
5835                    ) => {
5836                        self.mark_materialization_recovery_required(ownership.id());
5837                        return Err(failure(
5838                            NodeFailureCode::BackendOperationFailed,
5839                            "managed session environment resolution failed",
5840                        ));
5841                    }
5842                };
5843                let transport = match mode {
5844                    SessionMode::Pty => TransportKind::Pty,
5845                    SessionMode::Inline => TransportKind::Pipe,
5846                    SessionMode::Acp => TransportKind::Acp,
5847                };
5848                let overlay = match bundle_arguments {
5849                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5850                        NativeInstanceLaunchOverlay::new(
5851                            provider.clone(),
5852                            transport,
5853                            environment,
5854                            extra_args,
5855                        )
5856                        .map_err(|_| failure(
5857                            NodeFailureCode::BundleBindingMismatch,
5858                            "managed session bundle launch overlay is invalid",
5859                        ))?,
5860                    ),
5861                    None => PreparedNativeLaunchOverlay::Environment(
5862                        NativeLaunchEnvironmentOverlay::new(
5863                            provider.clone(),
5864                            transport,
5865                            environment,
5866                        )
5867                        .map_err(|_| {
5868                            failure(
5869                                NodeFailureCode::BackendOperationFailed,
5870                                "managed session environment overlay is invalid",
5871                            )
5872                        })?,
5873                    ),
5874                };
5875                Ok(Some((ownership.id().clone(), Some(overlay))))
5876            }
5877        }
5878    }
5879
5880    fn destroy_record_materialization(
5881        &self,
5882        record_id: &SessionRecordId,
5883    ) -> Result<bool, NodeFailure> {
5884        let ids = self
5885            .materializations
5886            .lock()
5887            .unwrap_or_else(|poisoned| poisoned.into_inner())
5888            .values()
5889            .filter_map(|record| {
5890                (record.owner()
5891                    == &MaterializationOwner::Record {
5892                        record_id: record_id.clone(),
5893                    })
5894                .then(|| record.id().clone())
5895            })
5896            .collect::<Vec<_>>();
5897        if ids.len() > 1 {
5898            return Err(failure(
5899                NodeFailureCode::BackendOperationFailed,
5900                "managed session has conflicting environment ownership",
5901            ));
5902        }
5903        let Some(id) = ids.first().cloned() else {
5904            return Ok(false);
5905        };
5906        let (terminal_record, mut cleanup_ownership) = {
5907            let _transaction = self
5908                .state_transaction
5909                .lock()
5910                .unwrap_or_else(|poisoned| poisoned.into_inner());
5911            let original_record = self.record(record_id)?;
5912            let mut terminal_record = original_record.clone();
5913            terminal_record.provider_session = None;
5914            terminal_record.active_session = None;
5915            terminal_record.state = ManagedSessionState::Unavailable;
5916            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5917            terminal_record.updated_at_unix_ms = unix_time_ms();
5918            let original_ownership = self
5919                .materializations
5920                .lock()
5921                .unwrap_or_else(|poisoned| poisoned.into_inner())
5922                .get(&id)
5923                .cloned()
5924                .ok_or_else(|| {
5925                    failure(
5926                        NodeFailureCode::BackendOperationFailed,
5927                        "managed session environment ownership disappeared during cleanup",
5928                    )
5929                })?;
5930            let mut cleanup_ownership = original_ownership.clone();
5931            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5932                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5933                    failure(
5934                        NodeFailureCode::BackendOperationFailed,
5935                        "managed session environment cleanup state is invalid",
5936                    )
5937                })?;
5938            }
5939            self.session_records
5940                .lock()
5941                .unwrap_or_else(|poisoned| poisoned.into_inner())
5942                .records
5943                .insert(record_id.clone(), terminal_record.clone());
5944            self.materializations
5945                .lock()
5946                .unwrap_or_else(|poisoned| poisoned.into_inner())
5947                .insert(id.clone(), cleanup_ownership.clone());
5948            if let Err(error) = self.persist_state_locked() {
5949                self.session_records
5950                    .lock()
5951                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5952                    .records
5953                    .insert(record_id.clone(), original_record);
5954                self.materializations
5955                    .lock()
5956                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5957                    .insert(id.clone(), original_ownership);
5958                return Err(persistence_failure(error));
5959            }
5960            (terminal_record, cleanup_ownership)
5961        };
5962        let cleanup = self
5963            .session_environment_materializer
5964            .as_ref()
5965            .ok_or(())
5966            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5967        if cleanup.is_err() {
5968            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5969            self.materializations
5970                .lock()
5971                .unwrap_or_else(|poisoned| poisoned.into_inner())
5972                .insert(id.clone(), cleanup_ownership);
5973            if self.persist_state().is_err() {
5974                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5975            }
5976            return Err(failure(
5977                NodeFailureCode::BackendOperationFailed,
5978                "managed session environment cleanup requires recovery",
5979            ));
5980        }
5981        let _transaction = self
5982            .state_transaction
5983            .lock()
5984            .unwrap_or_else(|poisoned| poisoned.into_inner());
5985        self.remove_record_memory(record_id);
5986        self.materializations
5987            .lock()
5988            .unwrap_or_else(|poisoned| poisoned.into_inner())
5989            .remove(&id);
5990        if let Err(error) = self.persist_state_locked() {
5991            self.session_records
5992                .lock()
5993                .unwrap_or_else(|poisoned| poisoned.into_inner())
5994                .records
5995                .insert(record_id.clone(), terminal_record);
5996            self.materializations
5997                .lock()
5998                .unwrap_or_else(|poisoned| poisoned.into_inner())
5999                .insert(id, cleanup_ownership);
6000            return Err(persistence_failure(error));
6001        }
6002        Ok(true)
6003    }
6004
6005    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6006        let changed = {
6007            let mut materializations = self
6008                .materializations
6009                .lock()
6010                .unwrap_or_else(|poisoned| poisoned.into_inner());
6011            let Some(ownership) = materializations.get_mut(id) else {
6012                return;
6013            };
6014            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6015        };
6016        if changed && self.persist_state().is_err() {
6017            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6018        }
6019    }
6020
6021    fn reconcile_materializations(&self) {
6022        let records = self
6023            .materializations
6024            .lock()
6025            .unwrap_or_else(|poisoned| poisoned.into_inner())
6026            .values()
6027            .cloned()
6028            .collect::<Vec<_>>();
6029        for mut ownership in records {
6030            let result = match (ownership.state(), ownership.owner()) {
6031                (MaterializationState::Preparing, _)
6032                | (MaterializationState::CleanupRequired, _) => {
6033                    self.cleanup_materialization(ownership.id())
6034                }
6035                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6036                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6037                        self.materializations
6038                            .lock()
6039                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6040                            .insert(ownership.id().clone(), ownership.clone());
6041                        self.persist_state().map_err(persistence_failure)
6042                    } else {
6043                        Err(failure(
6044                            NodeFailureCode::BackendOperationFailed,
6045                            "session environment reconciliation failed",
6046                        ))
6047                    }
6048                }
6049                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6050                    .session_environment_materializer
6051                    .as_ref()
6052                    .ok_or_else(|| {
6053                        failure(
6054                            NodeFailureCode::BackendOperationFailed,
6055                            "session environment materializer is unavailable",
6056                        )
6057                    })
6058                    .and_then(|materializer| {
6059                        materializer.revalidate(&ownership).map_err(|_| failure(
6060                            NodeFailureCode::BackendOperationFailed,
6061                            "session materialization revalidation failed",
6062                        ))?;
6063                        let record = self.record(record_id)?;
6064                        if record.environment_profile.as_ref() != ownership.environment_profile()
6065                            || record.bundle.as_ref() != ownership.bundle()
6066                            || record.context.as_ref() != ownership.context()
6067                        {
6068                            return Err(failure(
6069                                NodeFailureCode::BackendOperationFailed,
6070                                "session materialization ownership changed after restart",
6071                            ));
6072                        }
6073                        if let Some(receipt) = ownership.bundle() {
6074                            let catalog = self.bundle_catalog.read()
6075                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6076                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6077                                NodeFailureCode::UnknownBundle,
6078                                "session bundle is unavailable after restart",
6079                            ))?;
6080                            if bundle.receipt() != *receipt {
6081                                return Err(failure(
6082                                    NodeFailureCode::BundleBindingMismatch,
6083                                    "session bundle changed after restart",
6084                                ));
6085                            }
6086                            let layout = self.bundle_layout(
6087                                &record.provider,
6088                                record.mode,
6089                                record.environment_profile.as_ref(),
6090                                bundle,
6091                            )?;
6092                            materializer
6093                                .revalidate_bundle(&ownership, bundle, layout)
6094                                .map_err(|_| failure(
6095                                    NodeFailureCode::BundleMaterializationFailed,
6096                                    "session bundle revalidation failed",
6097                                ))?;
6098                        }
6099                        if let Some(receipt) = ownership.context() {
6100                            let pack = materializer
6101                                .revalidate_context(&ownership, receipt)
6102                                .map_err(|_| {
6103                                    failure(
6104                                        NodeFailureCode::ContextPackMaterializationFailed,
6105                                        "session context revalidation failed",
6106                                    )
6107                                })?;
6108                            self.context_catalog
6109                                .write()
6110                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6111                                .insert(pack)
6112                                .map_err(|_| {
6113                                    failure(
6114                                        NodeFailureCode::ContextPackMaterializationFailed,
6115                                        "session context catalog restore failed",
6116                                    )
6117                                })?;
6118                        }
6119                        Ok(())
6120                    }),
6121                (MaterializationState::RecoveryRequired, _) => Ok(()),
6122            };
6123            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6124                let _ = ownership.mark_recovery_required(unix_time_ms());
6125                self.materializations
6126                    .lock()
6127                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6128                    .insert(ownership.id().clone(), ownership);
6129                if self.persist_state().is_err() {
6130                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6131                }
6132            }
6133        }
6134        let materialized_profiles = self
6135            .environment_materialization_profiles
6136            .read()
6137            .unwrap_or_else(|poisoned| poisoned.into_inner())
6138            .keys()
6139            .cloned()
6140            .collect::<Vec<_>>();
6141        let ownership = self
6142            .materializations
6143            .lock()
6144            .unwrap_or_else(|poisoned| poisoned.into_inner())
6145            .values()
6146            .cloned()
6147            .collect::<Vec<_>>();
6148        let changed = {
6149            let mut session_records = self
6150                .session_records
6151                .lock()
6152                .unwrap_or_else(|poisoned| poisoned.into_inner());
6153            let mut changed = false;
6154            for record in session_records.records.values_mut() {
6155                let environment_profile = record.environment_profile.as_ref();
6156                if environment_profile.is_none()
6157                    && record.bundle.is_none()
6158                    && record.context.is_none()
6159                {
6160                    continue;
6161                }
6162                if record.bundle.is_none()
6163                    && environment_profile.is_some_and(|profile| {
6164                        !materialized_profiles.contains(&profile.profile_id)
6165                    })
6166                {
6167                    continue;
6168                }
6169                let has_exact_ownership = ownership.iter().any(|candidate| {
6170                    candidate.state() == MaterializationState::Ready
6171                        && candidate.environment_profile() == environment_profile
6172                        && candidate.bundle() == record.bundle.as_ref()
6173                        && candidate.context() == record.context.as_ref()
6174                        && candidate.owner()
6175                            == &MaterializationOwner::Record {
6176                                record_id: record.record_id.clone(),
6177                            }
6178                });
6179                if !has_exact_ownership {
6180                    record.provider_session = None;
6181                    record.active_session = None;
6182                    record.state = ManagedSessionState::Unavailable;
6183                    record.last_error = Some(if record.bundle.is_some() {
6184                        "bundle-unavailable".to_owned()
6185                    } else if record.context.is_some() {
6186                        "context-unavailable".to_owned()
6187                    } else {
6188                        "environment-profile-unavailable".to_owned()
6189                    });
6190                    record.updated_at_unix_ms = unix_time_ms();
6191                    changed = true;
6192                }
6193            }
6194            changed
6195        };
6196        if changed && self.persist_state().is_err() {
6197            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6198        }
6199    }
6200
6201    fn replay_spawn_spec(
6202        &self,
6203        spec: &SpawnSpec,
6204        now: Instant,
6205    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6206        let mut cache = self
6207            .spawn_idempotency
6208            .lock()
6209            .unwrap_or_else(|poisoned| poisoned.into_inner());
6210        cache.entries.retain(|_, entry| entry.expires_at > now);
6211        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6212            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6213                return Err(failure(
6214                    NodeFailureCode::SpawnIdempotencyConflict,
6215                    "spawn idempotency key was reused across spawn kinds",
6216                ));
6217            };
6218            if existing != spec {
6219                return Err(failure(
6220                    NodeFailureCode::SpawnIdempotencyConflict,
6221                    "spawn idempotency key was reused with a different specification",
6222                ));
6223            }
6224            return Ok(Some(result.clone()));
6225        }
6226        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6227            return Err(failure(
6228                NodeFailureCode::SpawnIdempotencyCapacity,
6229                "spawn idempotency cache reached its bounded capacity",
6230            ));
6231        }
6232        Ok(None)
6233    }
6234
6235    fn remember_spawn_spec(
6236        &self,
6237        spec: SpawnSpec,
6238        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6239        accepted_at: Instant,
6240    ) {
6241        self
6242            .spawn_idempotency
6243            .lock()
6244            .unwrap_or_else(|poisoned| poisoned.into_inner())
6245            .entries
6246            .insert(
6247                spec.idempotency_key.clone(),
6248                SpawnIdempotencyEntry {
6249                    value: SpawnIdempotencyValue::Standard { spec, result },
6250                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6251                },
6252            );
6253    }
6254
6255    fn claim_harness_mcp_spawn(
6256        &self,
6257        _reservation_id: &HarnessMcpReservationId,
6258        _activation_digest: &HarnessMcpActivationDigest,
6259        spec: &SpawnSpec,
6260        now: Instant,
6261    ) -> Result<(), NodeFailure> {
6262        let mut cache = self.spawn_idempotency.lock()
6263            .unwrap_or_else(|poisoned| poisoned.into_inner());
6264        cache.entries.retain(|_, entry| entry.expires_at > now);
6265        if cache.entries.contains_key(&spec.idempotency_key) {
6266            return Err(failure(
6267                NodeFailureCode::SpawnIdempotencyConflict,
6268                "spawn idempotency key was reused across spawn kinds",
6269            ));
6270        }
6271        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6272            return Err(failure(
6273                NodeFailureCode::SpawnIdempotencyCapacity,
6274                "spawn idempotency cache reached its bounded capacity",
6275            ));
6276        }
6277        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6278            value: SpawnIdempotencyValue::HarnessMcp,
6279            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280        });
6281        Ok(())
6282    }
6283
6284    fn replay_managed_spawn(
6285        &self,
6286        request: &ManagedWorktreeSpawnRequest,
6287        now: Instant,
6288    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6289        let mut cache = self.spawn_idempotency.lock()
6290            .unwrap_or_else(|poisoned| poisoned.into_inner());
6291        cache.entries.retain(|_, entry| entry.expires_at > now);
6292        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6293            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6294                return Err(failure(
6295                    NodeFailureCode::SpawnIdempotencyConflict,
6296                    "spawn idempotency key was reused across spawn kinds",
6297                ));
6298            };
6299            if existing != request {
6300                return Err(failure(
6301                    NodeFailureCode::SpawnIdempotencyConflict,
6302                    "spawn idempotency key was reused with a different managed specification",
6303                ));
6304            }
6305            return Ok(Some(result.clone()));
6306        }
6307        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308            return Err(failure(
6309                NodeFailureCode::SpawnIdempotencyCapacity,
6310                "spawn idempotency cache reached its bounded capacity",
6311            ));
6312        }
6313        Ok(None)
6314    }
6315
6316    fn remember_managed_spawn(
6317        &self,
6318        request: ManagedWorktreeSpawnRequest,
6319        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6320        accepted_at: Instant,
6321    ) {
6322        self.spawn_idempotency.lock()
6323            .unwrap_or_else(|poisoned| poisoned.into_inner())
6324            .entries
6325            .insert(
6326                request.spawn_spec.idempotency_key.clone(),
6327                SpawnIdempotencyEntry {
6328                    value: SpawnIdempotencyValue::Managed { request, result },
6329                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6330                },
6331            );
6332    }
6333
6334    fn remember_managed_spawn_attempt(
6335        &self,
6336        durable_v2_key: Option<&SpawnIdempotencyKey>,
6337        request: ManagedWorktreeSpawnRequest,
6338        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6339        accepted_at: Instant,
6340    ) {
6341        if durable_v2_key.is_none() {
6342            self.remember_managed_spawn(request, result, accepted_at);
6343        }
6344    }
6345
6346    fn acquire_durable_managed_spawn_v2(
6347        &self,
6348        request: &ManagedWorktreeSpawnRequestV2,
6349        request_digest: String,
6350    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6351        if self.state_path.is_none() {
6352            return Err(failure(
6353                NodeFailureCode::BackendOperationFailed,
6354                "managed worktree V2 requires durable node state",
6355            ));
6356        }
6357        let _transaction = self.state_transaction
6358            .lock()
6359            .unwrap_or_else(|poisoned| poisoned.into_inner());
6360        let key = request.spawn_spec.idempotency_key.clone();
6361        let mut previous = None;
6362        {
6363            let mut replays = self.managed_spawn_replays
6364                .lock()
6365                .unwrap_or_else(|poisoned| poisoned.into_inner());
6366            if let Some(record) = replays.get_mut(&key) {
6367                if record.request_digest != request_digest {
6368                    return Err(failure(
6369                        NodeFailureCode::SpawnIdempotencyConflict,
6370                        "managed worktree V2 idempotency key was reused with a different request",
6371                    ));
6372                }
6373                match &record.state {
6374                    ManagedWorktreeSpawnReplayStateV10::Pending {
6375                        owner_incarnation_id,
6376                        lease_id: None,
6377                    } if owner_incarnation_id != &self.incarnation_id => {
6378                        previous = Some(record.clone());
6379                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6380                            owner_incarnation_id: self.incarnation_id,
6381                            lease_id: None,
6382                        };
6383                        record.updated_at_unix_ms = unix_time_ms();
6384                    }
6385                    ManagedWorktreeSpawnReplayStateV10::Pending {
6386                        lease_id: None,
6387                        ..
6388                    } => {
6389                        return Err(failure(
6390                            NodeFailureCode::BackendBusy,
6391                            "managed worktree V2 reservation is already active",
6392                        ));
6393                    }
6394                    ManagedWorktreeSpawnReplayStateV10::Pending {
6395                        lease_id: Some(_),
6396                        ..
6397                    } => {
6398                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6399                    }
6400                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6401                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6402                            receipt.clone(),
6403                        ));
6404                    }
6405                }
6406            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6407                return Err(failure(
6408                    NodeFailureCode::SpawnIdempotencyCapacity,
6409                    "durable managed worktree replay ledger reached its bounded capacity",
6410                ));
6411            } else {
6412                let now = unix_time_ms();
6413                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6414                    idempotency_key: key.clone(),
6415                    request_digest,
6416                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6417                    profile_id: request.worktree_profile_id.clone(),
6418                    expected_profile_revision: request.expected_profile_revision.clone(),
6419                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6420                        owner_incarnation_id: self.incarnation_id,
6421                        lease_id: None,
6422                    },
6423                    created_at_unix_ms: now,
6424                    updated_at_unix_ms: now,
6425                });
6426            }
6427        }
6428        if let Err(error) = self.persist_state_locked() {
6429            let mut replays = self.managed_spawn_replays
6430                .lock()
6431                .unwrap_or_else(|poisoned| poisoned.into_inner());
6432            match previous {
6433                Some(previous) => {
6434                    replays.insert(key, previous);
6435                }
6436                None => {
6437                    replays.remove(&key);
6438                }
6439            }
6440            return Err(persistence_failure(error));
6441        }
6442        Ok(DurableManagedSpawnReplayDecision::Reserved)
6443    }
6444
6445    fn link_durable_managed_spawn_v2_lease_locked(
6446        &self,
6447        key: &SpawnIdempotencyKey,
6448        lease_id: &ManagedWorktreeLeaseId,
6449    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6450        let mut replays = self.managed_spawn_replays
6451            .lock()
6452            .unwrap_or_else(|poisoned| poisoned.into_inner());
6453        let record = replays.get_mut(key).ok_or_else(|| {
6454            failure(
6455                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6456                "managed worktree V2 reservation disappeared before allocation",
6457            )
6458        })?;
6459        let previous = record.clone();
6460        match &mut record.state {
6461            ManagedWorktreeSpawnReplayStateV10::Pending {
6462                lease_id: current,
6463                ..
6464            }
6465                if current.is_none() =>
6466            {
6467                *current = Some(lease_id.clone());
6468                record.updated_at_unix_ms = unix_time_ms();
6469                Ok(previous)
6470            }
6471            _ => Err(failure(
6472                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6473                "managed worktree V2 reservation is not allocation-ready",
6474            )),
6475        }
6476    }
6477
6478    fn restore_durable_managed_spawn_v2_locked(
6479        &self,
6480        key: &SpawnIdempotencyKey,
6481        previous: ManagedWorktreeSpawnReplayRecordV10,
6482    ) {
6483        self.managed_spawn_replays
6484            .lock()
6485            .unwrap_or_else(|poisoned| poisoned.into_inner())
6486            .insert(key.clone(), previous);
6487    }
6488
6489    fn commit_durable_managed_spawn_v2(
6490        &self,
6491        request: &ManagedWorktreeSpawnRequestV2,
6492        receipt: ManagedWorktreeSpawnReceipt,
6493    ) -> Result<(), NodeFailure> {
6494        let _transaction = self.state_transaction
6495            .lock()
6496            .unwrap_or_else(|poisoned| poisoned.into_inner());
6497        let key = &request.spawn_spec.idempotency_key;
6498        let previous = {
6499            let mut replays = self.managed_spawn_replays
6500                .lock()
6501                .unwrap_or_else(|poisoned| poisoned.into_inner());
6502            let record = replays.get_mut(key).ok_or_else(|| {
6503                failure(
6504                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6505                    "managed worktree V2 reservation disappeared before commit",
6506                )
6507            })?;
6508            let previous = record.clone();
6509            match &record.state {
6510                ManagedWorktreeSpawnReplayStateV10::Pending {
6511                    lease_id: Some(lease_id),
6512                    ..
6513                } if lease_id == &receipt.lease.lease_id => {}
6514                _ => {
6515                    return Err(failure(
6516                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6517                        "managed worktree V2 reservation is not linked to the spawned lease",
6518                    ));
6519                }
6520            }
6521            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6522            record.updated_at_unix_ms = unix_time_ms();
6523            previous
6524        };
6525        if let Err(error) = self.persist_state_locked() {
6526            self.restore_durable_managed_spawn_v2_locked(key, previous);
6527            return Err(persistence_failure(error));
6528        }
6529        Ok(())
6530    }
6531
6532    fn remove_unallocated_durable_managed_spawn_v2(
6533        &self,
6534        request: &ManagedWorktreeSpawnRequestV2,
6535    ) -> Result<(), NodeFailure> {
6536        let _transaction = self.state_transaction
6537            .lock()
6538            .unwrap_or_else(|poisoned| poisoned.into_inner());
6539        let key = &request.spawn_spec.idempotency_key;
6540        let removed = {
6541            let mut replays = self.managed_spawn_replays
6542                .lock()
6543                .unwrap_or_else(|poisoned| poisoned.into_inner());
6544            match replays.get(key).map(|record| &record.state) {
6545                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6546                    lease_id: None,
6547                    ..
6548                }) => {
6549                    replays.remove(key)
6550                }
6551                _ => None,
6552            }
6553        };
6554        let Some(removed) = removed else {
6555            return Ok(());
6556        };
6557        if let Err(error) = self.persist_state_locked() {
6558            self.managed_spawn_replays
6559                .lock()
6560                .unwrap_or_else(|poisoned| poisoned.into_inner())
6561                .insert(key.clone(), removed);
6562            return Err(persistence_failure(error));
6563        }
6564        Ok(())
6565    }
6566
6567    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6568        if spec.target.node_id != self.node_id {
6569            return Err(failure(
6570                NodeFailureCode::SpawnTargetMismatch,
6571                "spawn target does not match this node",
6572            ));
6573        }
6574        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6575            failure(
6576                NodeFailureCode::UnknownSpawnProfile,
6577                "spawn profile is unavailable on this node",
6578            )
6579        })?;
6580        let resolved = spec.resolve(defaults).map_err(|error| match error {
6581            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6582                NodeFailureCode::SpawnProfileRevisionMismatch,
6583                "spawn profile revision does not match the loaded profile",
6584            ),
6585            _ => failure(
6586                NodeFailureCode::InvalidRequest,
6587                "spawn specification could not be resolved",
6588            ),
6589        })?;
6590        if resolved.target.worktree_id.is_some() {
6591            self.require_worktree_service(&resolved.target.workspace_id)?;
6592        }
6593        let environment_profile = self.resolve_environment_profile(&resolved)?;
6594        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6595        self.resolve_context(&resolved)?;
6596        Ok(resolved)
6597    }
6598
6599    async fn resolve_spawn_workspace(
6600        &self,
6601        resolved: &ResolvedSpawnSpec,
6602        deadline: Instant,
6603    ) -> Result<WorkspaceId, NodeFailure> {
6604        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6605            return Ok(resolved.target.workspace_id.clone());
6606        };
6607        if self.managed_worktrees.lock()
6608            .unwrap_or_else(|poisoned| poisoned.into_inner())
6609            .lease_for_workspace(worktree_id)
6610            .is_some()
6611        {
6612            return Err(failure(
6613                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6614                "managed worktree targets may only be spawned through their lease request",
6615            ));
6616        }
6617        self.require_worktree_service(&resolved.target.workspace_id)?;
6618        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6619        let selected_root = self.workspace_root(worktree_id)?;
6620        let remaining = spawn_deadline_remaining(deadline)?;
6621        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6622            .await
6623            .map_err(|_| {
6624                failure(
6625                    NodeFailureCode::SpawnDeadlineExceeded,
6626                    "Git worktree selection exceeded the spawn deadline",
6627                )
6628            })?
6629            .map_err(git_worktree_failure)?;
6630        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6631        Ok(worktree_id.clone())
6632    }
6633
6634    async fn spawn_from_spec(
6635        &self,
6636        spec: SpawnSpec,
6637    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6638        self.spawn_from_spec_with_proxy(spec, None).await
6639    }
6640
6641    async fn spawn_from_spec_with_proxy(
6642        &self,
6643        spec: SpawnSpec,
6644        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6645    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6646        let accepted_at = Instant::now();
6647        if harness_mcp.is_none() {
6648            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6649                return replayed;
6650            }
6651        }
6652        let resolved = self.resolve_spawn_spec(&spec)?;
6653        let environment_profile = self.resolve_environment_profile(&resolved)?;
6654        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6655        let context = self.resolve_context(&resolved)?;
6656        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6657        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6658        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6659        let spawn_workspace_id = match spawn_workspace_id {
6660            Ok(workspace_id) => workspace_id,
6661            Err(error) => {
6662                let result = Err(error);
6663                if harness_mcp.is_none() {
6664                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6665                }
6666                return result;
6667            }
6668        };
6669        if self.managed_worktrees.lock()
6670            .unwrap_or_else(|poisoned| poisoned.into_inner())
6671            .lease_for_workspace(&spawn_workspace_id)
6672            .is_some()
6673        {
6674            let result = Err(failure(
6675                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6676                "managed worktree targets may only be spawned through their lease request",
6677            ));
6678            if harness_mcp.is_none() {
6679                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6680            }
6681            return result;
6682        }
6683        let result = self
6684            .spawn_session_with_deadline(
6685                spawn_workspace_id,
6686                resolved.provider.clone(),
6687                resolved.mode,
6688                resolved.terminal_size,
6689                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6690                environment_profile.clone(),
6691                bundle.clone(),
6692                context.clone(),
6693                None,
6694                None,
6695                SpawnRecordPolicy::Always,
6696                Some(deadline),
6697                &required_capabilities,
6698                harness_mcp,
6699                resolved.approval_level,
6700                resolved.network_allowlist.clone(),
6701                resolved.browser_profile_id.clone(),
6702            )
6703            .await
6704            .map(|(session, _runtime_policy)| {
6705                resolved.receipt_with_materialization(
6706                    self.incarnation_id,
6707                    session,
6708                    environment_profile,
6709                    bundle,
6710                    context,
6711                )
6712            });
6713        if harness_mcp.is_none() {
6714            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6715        }
6716        result
6717    }
6718
6719    async fn spawn_from_spec_with_harness_mcp(
6720        &self,
6721        reservation_id: HarnessMcpReservationId,
6722        activation_digest: HarnessMcpActivationDigest,
6723        spec: SpawnSpec,
6724        deadline_unix_ms: u64,
6725    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6726        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6727            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6728        })?;
6729        if let Some(receipt) = registry
6730            .replay_spawn(&reservation_id, &activation_digest, &spec)
6731            .map_err(harness_mcp_failure)?
6732        {
6733            return Ok(receipt);
6734        }
6735        let resolved = self.resolve_spawn_spec(&spec)?;
6736        let prepared = registry
6737            .prepare_spawn(
6738                &reservation_id,
6739                &activation_digest,
6740                &spec,
6741                deadline_unix_ms,
6742                resolved.provider.clone(),
6743            )
6744            .map_err(harness_mcp_failure)?;
6745        self.claim_harness_mcp_spawn(
6746            &reservation_id,
6747            &activation_digest,
6748            &spec,
6749            Instant::now(),
6750        )?;
6751        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6752        let mut receipt = match spawn {
6753            Ok(receipt) => receipt,
6754            Err(error) => {
6755                let _ = registry.abort(&reservation_id, &activation_digest);
6756                return Err(error);
6757            }
6758        };
6759        let record_id = self
6760            .session_bindings
6761            .lock()
6762            .unwrap_or_else(|poisoned| poisoned.into_inner())
6763            .get(&receipt.session.session.instance_id)
6764            .and_then(|binding| binding.record_id.clone());
6765        let record_id = match record_id {
6766            Some(record_id) => record_id,
6767            None => {
6768                let rollback = self.remove_session(&receipt.session).await;
6769                let _ = registry.abort(&reservation_id, &activation_digest);
6770                if let Err(rollback_error) = rollback {
6771                    return Err(rollback_error);
6772                }
6773                return Err(failure(
6774                    NodeFailureCode::BindingMismatch,
6775                    "harness MCP spawn did not retain its exact managed record",
6776                ));
6777            }
6778        };
6779        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6780            reservation_id: reservation_id.clone(),
6781            activation_digest: activation_digest.clone(),
6782        });
6783        if let Err(error) = registry.mark_spawned(
6784            &prepared,
6785            receipt.session.clone(),
6786            record_id,
6787            receipt.clone(),
6788        ) {
6789            let rollback = self.remove_session(&receipt.session).await;
6790            let _ = registry.abort(&reservation_id, &activation_digest);
6791            if let Err(rollback_error) = rollback {
6792                return Err(rollback_error);
6793            }
6794            return Err(harness_mcp_failure(error));
6795        }
6796        Ok(receipt)
6797    }
6798
6799    async fn spawn_managed_worktree(
6800        &self,
6801        request: ManagedWorktreeSpawnRequest,
6802    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6803        self.spawn_managed_worktree_inner(request, None).await
6804    }
6805
6806    async fn spawn_managed_worktree_inner(
6807        &self,
6808        request: ManagedWorktreeSpawnRequest,
6809        durable_v2_key: Option<&SpawnIdempotencyKey>,
6810    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6811        let accepted_at = Instant::now();
6812        if durable_v2_key.is_none() {
6813            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6814                return replayed;
6815            }
6816        }
6817        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6818        if request.spawn_spec.target.worktree_id.is_some() {
6819            let result = Err(failure(
6820                NodeFailureCode::InvalidRequest,
6821                "managed worktree spawn must not select a caller-provided worktree",
6822            ));
6823            self.remember_managed_spawn_attempt(
6824                durable_v2_key,
6825                request,
6826                result.clone(),
6827                accepted_at,
6828            );
6829            return result;
6830        }
6831        let environment_profile = self.resolve_environment_profile(&resolved)?;
6832        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6833        let context = self.resolve_context(&resolved)?;
6834        let source_workspace_id = resolved.target.workspace_id.clone();
6835        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6836        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6837        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6838        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6839            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6840            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6841            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6842            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6843        };
6844        let runtime_policy = timeout(
6845            spawn_deadline_remaining(deadline)?,
6846            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6847        ).await.map_err(|_| failure(
6848            NodeFailureCode::SpawnDeadlineExceeded,
6849            "provider admission exceeded the managed spawn deadline",
6850        ))??;
6851        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6852            return Err(failure(
6853                NodeFailureCode::UnsupportedSpawnCapability,
6854                "provider runtime does not admit a required spawn capability",
6855            ));
6856        }
6857        self.ensure_binding_capacity()?;
6858        let source_root = self.workspace_root(&source_workspace_id)?;
6859        profile.validate_for_workspace(&source_root)
6860            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6861        let base_timeout_ms = u64::try_from(
6862            spawn_deadline_remaining(deadline)?.as_millis(),
6863        ).unwrap_or(u64::MAX).max(1);
6864        let base_commit = resolve_base_commit_with_timeout(
6865            &source_root,
6866            profile.base(),
6867            base_timeout_ms,
6868        ).await.map_err(|error| {
6869            if Instant::now() >= deadline || error.message.contains("timed out") {
6870                failure(
6871                    NodeFailureCode::SpawnDeadlineExceeded,
6872                    "managed worktree base resolution exceeded the spawn deadline",
6873                )
6874            } else {
6875                managed_git_worktree_failure(error)
6876            }
6877        })?;
6878
6879        let lease = {
6880            let _transaction = self.state_transaction.lock()
6881                .unwrap_or_else(|poisoned| poisoned.into_inner());
6882            let previous = self.managed_worktrees.lock()
6883                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6884            let lease = self.managed_worktrees.lock()
6885                .unwrap_or_else(|poisoned| poisoned.into_inner())
6886                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6887                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6888            let previous_replay = match durable_v2_key
6889                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6890                .transpose()
6891            {
6892                Ok(previous) => previous,
6893                Err(error) => {
6894                    *self.managed_worktrees.lock()
6895                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6896                    return Err(error);
6897                }
6898            };
6899            if let Err(error) = self.persist_state_locked() {
6900                *self.managed_worktrees.lock()
6901                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6902                if let (Some(key), Some(previous_replay)) =
6903                    (durable_v2_key, previous_replay)
6904                {
6905                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6906                }
6907                return Err(persistence_failure(error));
6908            }
6909            lease
6910        };
6911        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6912        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6913            self.managed_worktrees.lock()
6914                .unwrap_or_else(|poisoned| poisoned.into_inner())
6915                .remove_unmutated(&lease.lease_id);
6916            if self.persist_state().is_err() {
6917                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6918            }
6919            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6920            self.remember_managed_spawn_attempt(
6921                durable_v2_key,
6922                request,
6923                result.clone(),
6924                accepted_at,
6925            );
6926            return result;
6927        }
6928        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6929            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6930            Err(error) => {
6931                self.reconcile_failed_allocation(&lease, &source_root).await;
6932                let result = Err(error);
6933                self.remember_managed_spawn_attempt(
6934                    durable_v2_key,
6935                    request,
6936                    result.clone(),
6937                    accepted_at,
6938                );
6939                return result;
6940            }
6941        };
6942        let created = match create_git_worktree_with_timeout(
6943                &source_root,
6944                &lease.target_root,
6945                &lease.branch,
6946                Some(&base_commit),
6947                mutation_timeout_ms,
6948            ).await {
6949            Ok(created) => created,
6950            Err(error) => {
6951                self.reconcile_failed_allocation(&lease, &source_root).await;
6952                let result = Err(if Instant::now() >= deadline
6953                    || error.message.contains("timed out")
6954                    || error.message.contains("deadline")
6955                {
6956                    failure(
6957                        NodeFailureCode::SpawnDeadlineExceeded,
6958                        "managed worktree creation exceeded the spawn deadline",
6959                    )
6960                } else {
6961                    managed_git_worktree_failure(error)
6962                });
6963                self.remember_managed_spawn_attempt(
6964                    durable_v2_key,
6965                    request,
6966                    result.clone(),
6967                    accepted_at,
6968                );
6969                return result;
6970            }
6971        };
6972        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6973            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6974            let result = Err(failure(
6975                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6976                "created worktree identity did not match its durable lease",
6977            ));
6978            self.remember_managed_spawn_attempt(
6979                durable_v2_key,
6980                request,
6981                result.clone(),
6982                accepted_at,
6983            );
6984            return result;
6985        }
6986        {
6987            let mut registry = self.managed_worktrees.lock()
6988                .unwrap_or_else(|poisoned| poisoned.into_inner());
6989            let current = registry.get_mut(&lease.lease_id)
6990                .expect("allocated managed worktree lease remains present");
6991            current.expected_head = Some(created.head.clone());
6992            current.state = ManagedWorktreeLeaseState::Ready;
6993            current.updated_at_unix_ms = unix_time_ms();
6994        }
6995        if let Err(error) = self.persist_state() {
6996            self.mark_managed_recovery_required(
6997                &lease.lease_id,
6998                ManagedWorktreeCleanupFailure::Backend,
6999            );
7000            let result = Err(persistence_failure(error));
7001            self.remember_managed_spawn_attempt(
7002                durable_v2_key,
7003                request,
7004                result.clone(),
7005                accepted_at,
7006            );
7007            return result;
7008        }
7009        let ready = self.managed_worktrees.lock()
7010            .unwrap_or_else(|poisoned| poisoned.into_inner())
7011            .get(&lease.lease_id)
7012            .expect("ready managed worktree lease remains present")
7013            .snapshot();
7014        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7015        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7016            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7017            let result = Err(error);
7018            self.remember_managed_spawn_attempt(
7019                durable_v2_key,
7020                request,
7021                result.clone(),
7022                accepted_at,
7023            );
7024            return result;
7025        }
7026        let spawn = self.spawn_session_with_deadline(
7027            lease.workspace_id.clone(),
7028            resolved.provider.clone(),
7029            resolved.mode,
7030            resolved.terminal_size,
7031            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7032            environment_profile.clone(),
7033            bundle.clone(),
7034            context.clone(),
7035            Some(lease.lease_id.clone()),
7036            Some(runtime_policy),
7037            SpawnRecordPolicy::Always,
7038            Some(deadline),
7039            &required_capabilities,
7040            None,
7041            resolved.approval_level,
7042            resolved.network_allowlist.clone(),
7043            resolved.browser_profile_id.clone(),
7044        ).await;
7045        let (session, effective_runtime_policy) = match spawn {
7046            Ok(spawned) => spawned,
7047            Err(error) => {
7048                if self
7049                    .cleanup_managed_worktree(&lease.lease_id, true)
7050                    .await
7051                    .is_err()
7052                {
7053                    self.mark_managed_recovery_required(
7054                        &lease.lease_id,
7055                        ManagedWorktreeCleanupFailure::Backend,
7056                    );
7057                }
7058                let result = Err(error);
7059                self.remember_managed_spawn_attempt(
7060                    durable_v2_key,
7061                    request,
7062                    result.clone(),
7063                    accepted_at,
7064                );
7065                return result;
7066            }
7067        };
7068        let (record_id, raw_inventory_record_id) = {
7069            let mut bindings = self.session_bindings.lock()
7070                .unwrap_or_else(|poisoned| poisoned.into_inner());
7071            let binding = bindings.get_mut(&session.session.instance_id)
7072                .expect("successful spawn retains its session binding");
7073            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7074            managed_spawn_record_ownership(binding, effective_runtime_policy)
7075        };
7076        let bound = self.managed_worktrees.lock()
7077            .unwrap_or_else(|poisoned| poisoned.into_inner())
7078            .bind_session(
7079                &lease.lease_id,
7080                ManagedWorktreeSessionHolder {
7081                    incarnation_id: self.incarnation_id,
7082                    instance_id: session.session.instance_id,
7083                    generation: session.session.generation,
7084                },
7085                record_id,
7086                unix_time_ms(),
7087            )
7088            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7089        let snapshot = match bound {
7090            Ok(snapshot) => snapshot,
7091            Err(error) => {
7092                if raw_inventory_record_id
7093                    .as_ref()
7094                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7095                {
7096                    self.mark_managed_recovery_required(
7097                        &lease.lease_id,
7098                        ManagedWorktreeCleanupFailure::Backend,
7099                    );
7100                }
7101                let _ = self.remove_session(&session).await;
7102                self.mark_managed_recovery_required(
7103                    &lease.lease_id,
7104                    ManagedWorktreeCleanupFailure::Backend,
7105                );
7106                let result = Err(error);
7107                self.remember_managed_spawn_attempt(
7108                    durable_v2_key,
7109                    request,
7110                    result.clone(),
7111                    accepted_at,
7112                );
7113                return result;
7114            }
7115        };
7116        if let Err(error) = self.persist_state() {
7117            if raw_inventory_record_id
7118                .as_ref()
7119                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120            {
7121                self.mark_managed_recovery_required(
7122                    &lease.lease_id,
7123                    ManagedWorktreeCleanupFailure::Backend,
7124                );
7125            }
7126            let _ = self.remove_session(&session).await;
7127            if self
7128                .cleanup_managed_worktree(&lease.lease_id, true)
7129                .await
7130                .is_err()
7131            {
7132                self.mark_managed_recovery_required(
7133                    &lease.lease_id,
7134                    ManagedWorktreeCleanupFailure::Backend,
7135                );
7136            }
7137            let result = Err(persistence_failure(error));
7138            self.remember_managed_spawn_attempt(
7139                durable_v2_key,
7140                request,
7141                result.clone(),
7142                accepted_at,
7143            );
7144            return result;
7145        }
7146        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7147        let receipt = managed_spawn_receipt(
7148            &resolved,
7149            self.incarnation_id,
7150            session,
7151            snapshot,
7152            environment_profile,
7153            bundle,
7154            context,
7155        );
7156        let result = Ok(receipt);
7157        self.remember_managed_spawn_attempt(
7158            durable_v2_key,
7159            request,
7160            result.clone(),
7161            accepted_at,
7162        );
7163        result
7164    }
7165
7166    async fn spawn_managed_worktree_v2(
7167        &self,
7168        request: ManagedWorktreeSpawnRequestV2,
7169    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7170        if request.spawn_spec.target.node_id != self.node_id {
7171            return Err(failure(
7172                NodeFailureCode::SpawnTargetMismatch,
7173                "spawn target does not match this node",
7174            ));
7175        }
7176        if request.spawn_spec.target.worktree_id.is_some() {
7177            return Err(failure(
7178                NodeFailureCode::InvalidRequest,
7179                "managed worktree spawn must not select a caller-provided worktree",
7180            ));
7181        }
7182        let request_digest = managed_spawn_request_digest_v2(&request)?;
7183        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7184        match replay {
7185            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7186            DurableManagedSpawnReplayDecision::PendingLinked => {
7187                return Err(failure(
7188                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7189                    "managed worktree V2 reservation has an incomplete durable allocation",
7190                ));
7191            }
7192            DurableManagedSpawnReplayDecision::Reserved => {}
7193        }
7194        let validated = (|| {
7195            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7196            let profile = self.managed_profile(
7197                &resolved.target.workspace_id,
7198                &request.worktree_profile_id,
7199            )?;
7200            if profile.revision() != &request.expected_profile_revision {
7201                return Err(failure(
7202                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7203                    "managed worktree profile revision does not match the loaded profile",
7204                ));
7205            }
7206            Ok(())
7207        })();
7208        match validated {
7209            Ok(()) => {}
7210            Err(error) => {
7211                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7212                return Err(error);
7213            }
7214        }
7215        let legacy = ManagedWorktreeSpawnRequest {
7216            spawn_spec: request.spawn_spec.clone(),
7217            worktree_profile_id: request.worktree_profile_id.clone(),
7218        };
7219        match self
7220            .spawn_managed_worktree_inner(
7221                legacy,
7222                Some(&request.spawn_spec.idempotency_key),
7223            )
7224            .await
7225        {
7226            Ok(receipt) => {
7227                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7228                Ok(receipt)
7229            }
7230            Err(error) => {
7231                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7232                Err(error)
7233            }
7234        }
7235    }
7236
7237    fn managed_profile(
7238        &self,
7239        workspace_id: &WorkspaceId,
7240        profile_id: &WorktreeProfileId,
7241    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7242        if self.worktree_service_modes.get(workspace_id).copied()
7243            != Some(WorktreeServiceMode::Managed)
7244        {
7245            return Err(failure(
7246                NodeFailureCode::UnsupportedSpawnCapability,
7247                "workspace does not enable managed worktrees",
7248            ));
7249        }
7250        self.managed_worktree_profiles.get(workspace_id)
7251            .and_then(|profiles| profiles.get(profile_id))
7252            .cloned()
7253            .ok_or_else(|| failure(
7254                NodeFailureCode::InvalidRequest,
7255                "managed worktree profile is unavailable for this workspace",
7256            ))
7257    }
7258
7259    async fn reconcile_failed_allocation(
7260        &self,
7261        lease: &ManagedWorktreeLeaseRecord,
7262        source_root: &str,
7263    ) {
7264        match list_git_worktrees(source_root).await {
7265            Ok(worktrees) => {
7266                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7267                    None => {
7268                        match std::fs::symlink_metadata(&lease.target_root) {
7269                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7270                                self.managed_worktrees.lock()
7271                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7272                                    .remove_unmutated(&lease.lease_id);
7273                                let _ = self.persist_state();
7274                            }
7275                            _ => self.mark_managed_recovery_required(
7276                                &lease.lease_id,
7277                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7278                            ),
7279                        }
7280                    }
7281                    Some(item) if exact_owned_worktree(
7282                        lease,
7283                        &item.path,
7284                        item.branch.as_deref(),
7285                        &item.head,
7286                    ) => {
7287                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7288                    }
7289                    Some(_) => self.mark_managed_recovery_required(
7290                        &lease.lease_id,
7291                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7292                    ),
7293                }
7294            }
7295            Err(_) => self.mark_managed_recovery_required(
7296                &lease.lease_id,
7297                ManagedWorktreeCleanupFailure::Backend,
7298            ),
7299        }
7300    }
7301
7302    async fn reconcile_managed_worktrees(&self) {
7303        let leases = self.managed_worktrees.lock()
7304            .unwrap_or_else(|poisoned| poisoned.into_inner())
7305            .active_records()
7306            .cloned()
7307            .collect::<Vec<_>>();
7308        for lease in leases {
7309            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7310                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7311                _ => {
7312                    self.mark_managed_recovery_required(
7313                        &lease.lease_id,
7314                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7315                    );
7316                    continue;
7317                }
7318            };
7319            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7320                self.mark_managed_recovery_required(
7321                    &lease.lease_id,
7322                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7323                );
7324                continue;
7325            }
7326            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7327                Ok(root) => root,
7328                Err(_) => {
7329                    self.mark_managed_recovery_required(
7330                        &lease.lease_id,
7331                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7332                    );
7333                    continue;
7334                }
7335            };
7336            let worktrees = match list_git_worktrees(&source_root).await {
7337                Ok(worktrees) => worktrees,
7338                Err(_) => {
7339                    self.mark_managed_recovery_required(
7340                        &lease.lease_id,
7341                        ManagedWorktreeCleanupFailure::Backend,
7342                    );
7343                    continue;
7344                }
7345            };
7346            let listed = worktrees.iter()
7347                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7348            let Some(listed) = listed else {
7349                match std::fs::symlink_metadata(&lease.target_root) {
7350                    Err(error) if error.kind() == io::ErrorKind::NotFound
7351                        && lease.state == ManagedWorktreeLeaseState::Allocating
7352                        && !lease.has_holders() => {
7353                            let removed = self.managed_worktrees.lock()
7354                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7355                                .tombstone(&lease.lease_id, unix_time_ms());
7356                            if self.persist_state().is_ok() && removed.is_some() {
7357                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7358                                    lease_id: lease.lease_id.clone(),
7359                                });
7360                            }
7361                        }
7362                    _ => self.mark_managed_recovery_required(
7363                        &lease.lease_id,
7364                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7365                    ),
7366                }
7367                continue;
7368            };
7369            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7370                &lease,
7371                &listed.path,
7372                listed.branch.as_deref(),
7373                &listed.head,
7374            ) {
7375                self.mark_managed_recovery_required(
7376                    &lease.lease_id,
7377                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7378                );
7379                continue;
7380            }
7381            if !self.workspaces.read()
7382                .unwrap_or_else(|poisoned| poisoned.into_inner())
7383                .contains_key(&lease.workspace_id)
7384            {
7385                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7386                    self.mark_managed_recovery_required(
7387                        &lease.lease_id,
7388                        ManagedWorktreeCleanupFailure::Backend,
7389                    );
7390                    continue;
7391                }
7392            }
7393            let snapshot = {
7394                let mut registry = self.managed_worktrees.lock()
7395                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7396                let current = registry.get_mut(&lease.lease_id)
7397                    .expect("reconciled managed lease remains present");
7398                current.expected_head = Some(listed.head.clone());
7399                current.cleanup_failure = None;
7400                current.state = if current.has_holders() {
7401                    ManagedWorktreeLeaseState::InUse
7402                } else if current.retention == ManagedWorktreeRetention::Retain {
7403                    ManagedWorktreeLeaseState::Retained
7404                } else {
7405                    ManagedWorktreeLeaseState::Ready
7406                };
7407                current.updated_at_unix_ms = unix_time_ms();
7408                current.snapshot()
7409            };
7410            if self.persist_state().is_err() {
7411                self.mark_managed_recovery_required(
7412                    &lease.lease_id,
7413                    ManagedWorktreeCleanupFailure::Backend,
7414                );
7415                continue;
7416            }
7417            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7418            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7419                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7420            }
7421        }
7422    }
7423
7424    /// Crash-recovery startup sweep: durably capture the pack for every
7425    /// `Live` managed record whose most recent session has not yet produced
7426    /// one (recovers a session whose `Exited{0}` event was observed but the
7427    /// Node crashed before the export finished). The reactive fast path for
7428    /// a single just-exited record does not go through this — see
7429    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7430    /// it can capture the exact `SessionAddress` before `publish_control`
7431    /// clears it, rather than re-deriving it from (by then likely already
7432    /// `Dormant`) record state the way a rescan naturally would.
7433    ///
7434    /// Returns as soon as every eligible record's export has been *spawned*,
7435    /// not completed — each one runs on its own detached task (see
7436    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7437    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7438    /// point this is called from `NodeServer::run()`, so nothing could ever
7439    /// drain the history round trip if this awaited it inline here either).
7440    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7441    /// is a silent skip for THIS record — an unusable source or a racing
7442    /// record is not surfaced to any caller.
7443    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7444        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7445            .session_records
7446            .lock()
7447            .unwrap_or_else(|poisoned| poisoned.into_inner())
7448            .records
7449            .values()
7450            .filter(|record| {
7451                record.state == ManagedSessionState::Live
7452                    && record.exported_context.is_none()
7453                    && record.provider_session.is_some()
7454            })
7455            .filter_map(|record| {
7456                record
7457                    .active_session
7458                    .clone()
7459                    .map(|session| (record.record_id.clone(), session))
7460            })
7461            .collect();
7462        for (record_id, session) in candidates {
7463            let shared = Arc::clone(self);
7464            tokio::spawn(async move {
7465                shared
7466                    .reconcile_context_pack_export_for_record(&record_id, session)
7467                    .await;
7468            });
7469        }
7470    }
7471
7472    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7473    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7474    /// round trip this performs (`discover_history`/`load_history`, via
7475    /// `export_context_pack_for_session_record`) can only ever settle once
7476    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7477    /// completion — and `tick()` is exclusively owned by, and only ever
7478    /// called from, that same drive-loop task. Awaiting this inline there
7479    /// would starve `tick()` and this call could never observe its own
7480    /// command complete.
7481    ///
7482    /// Because this now genuinely races the drive loop's own
7483    /// `publish_control` (which downgrades this exact record from `Live` to
7484    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7485    /// triggered this reconcile), the record legitimately may already be
7486    /// `Dormant` by the time this reads it, or may transition mid-flight —
7487    /// `allow_clean_detachment: true` on the downstream export call is what
7488    /// tolerates exactly that transition (and only that transition; see
7489    /// `session_record_export_target_matches`).
7490    async fn reconcile_context_pack_export_for_record(
7491        &self,
7492        record_id: &SessionRecordId,
7493        session: SessionAddress,
7494    ) {
7495        let Ok(record) = self.record(record_id) else {
7496            return;
7497        };
7498        if !matches!(
7499            record.state,
7500            ManagedSessionState::Live | ManagedSessionState::Dormant
7501        ) || record.exported_context.is_some()
7502            || record.provider_session.is_none()
7503        {
7504            return;
7505        }
7506        // Pack bytes are already durably committed inside this call, via
7507        // commit_context_pack_for_session_record's own durable
7508        // write-through (context_pack_store) — no separate step here.
7509        // Unclean exits (non-zero/forced/no loadable history) are already
7510        // rejected by context_pack_source_status_is_usable inside
7511        // materialize_context_pack: this simply returns Err and is skipped.
7512        let Ok(receipt) = self
7513            .export_context_pack_for_session_record(&record.record_id, &session, true)
7514            .await
7515        else {
7516            return;
7517        };
7518        self.set_exported_context(&record.record_id, receipt);
7519    }
7520
7521    /// Records the record's most recent clean-exit pack. No-op (not an
7522    /// error) if the record disappeared, or if it already carries an
7523    /// `exported_context` — first-write-wins, matching the harness-side
7524    /// idempotent reconciliation that consumes this field.
7525    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7526        let transaction = self
7527            .state_transaction
7528            .lock()
7529            .unwrap_or_else(|poisoned| poisoned.into_inner());
7530        let (previous, updated) = {
7531            let mut records = self
7532                .session_records
7533                .lock()
7534                .unwrap_or_else(|poisoned| poisoned.into_inner());
7535            let Some(record) = records.records.get_mut(record_id) else {
7536                return;
7537            };
7538            if record.exported_context.is_some() {
7539                return;
7540            }
7541            let previous = record.clone();
7542            record.exported_context = Some(receipt);
7543            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7544            (previous, record.clone())
7545        };
7546        if self.persist_state_locked().is_err() {
7547            self.session_records
7548                .lock()
7549                .unwrap_or_else(|poisoned| poisoned.into_inner())
7550                .records
7551                .insert(record_id.clone(), previous);
7552            return;
7553        }
7554        drop(transaction);
7555        self.publish_record(updated);
7556    }
7557
7558    /// Resolves the managed record and its exact `SessionAddress` currently
7559    /// bound to `instance_id` at exactly `generation`, used by the reactive
7560    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7561    /// record without a full rescan. Mirrors the lookup already inlined in
7562    /// `publish_control`. Captured synchronously, before `publish_control`
7563    /// for the same event clears the record's own `active_session` — the
7564    /// caller passes the resolved address through to a detached
7565    /// reconciliation task rather than re-deriving it from the record later,
7566    /// after that race has necessarily already happened.
7567    fn managed_record_export_target_for_instance(
7568        &self,
7569        instance_id: AgentInstanceId,
7570        generation: SessionGeneration,
7571    ) -> Option<(SessionRecordId, SessionAddress)> {
7572        let bindings = self
7573            .session_bindings
7574            .lock()
7575            .unwrap_or_else(|poisoned| poisoned.into_inner());
7576        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7577        let record_id = binding.record_id.clone()?;
7578        Some((
7579            record_id,
7580            SessionAddress {
7581                workspace_id: binding.workspace_id.clone(),
7582                session: SessionKey { instance_id, generation },
7583            },
7584        ))
7585    }
7586
7587    fn mark_managed_recovery_required(
7588        &self,
7589        lease_id: &ManagedWorktreeLeaseId,
7590        failure_kind: ManagedWorktreeCleanupFailure,
7591    ) {
7592        let snapshot = {
7593            let mut registry = self.managed_worktrees.lock()
7594                .unwrap_or_else(|poisoned| poisoned.into_inner());
7595            let Some(lease) = registry.get_mut(lease_id) else { return };
7596            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7597            lease.cleanup_failure = Some(failure_kind);
7598            lease.updated_at_unix_ms = unix_time_ms();
7599            lease.snapshot()
7600        };
7601        let _ = self.persist_state();
7602        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7603    }
7604
7605    fn set_persistence_error(&self, error: Option<String>) {
7606        *self
7607            .persistence_error
7608            .write()
7609            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7610            .map(|message| session_registry::sanitized_persistence_summary(&message));
7611    }
7612
7613    fn persist_state(&self) -> io::Result<()> {
7614        let _transaction = self
7615            .state_transaction
7616            .lock()
7617            .unwrap_or_else(|poisoned| poisoned.into_inner());
7618        self.persist_state_locked()
7619    }
7620
7621    fn persist_state_locked(&self) -> io::Result<()> {
7622        let workspaces = self
7623            .workspaces
7624            .read()
7625            .unwrap_or_else(|poisoned| poisoned.into_inner())
7626            .clone();
7627        let records = self
7628            .session_records
7629            .lock()
7630            .unwrap_or_else(|poisoned| poisoned.into_inner())
7631            .records
7632            .values()
7633            .cloned()
7634            .collect::<Vec<_>>();
7635        let mut managed = self.managed_worktrees
7636            .lock()
7637            .unwrap_or_else(|poisoned| poisoned.into_inner());
7638        managed.reattach_record_holders(
7639            &managed_worktree_record_holders(&records),
7640            unix_time_ms(),
7641        );
7642        let materializations = self
7643            .materializations
7644            .lock()
7645            .unwrap_or_else(|poisoned| poisoned.into_inner())
7646            .values()
7647            .cloned()
7648            .collect::<Vec<_>>();
7649        let managed_spawn_replays = self.managed_spawn_replays
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner())
7652            .values()
7653            .cloned()
7654            .collect::<Vec<_>>();
7655        let result = session_registry::save_v10(
7656            self.state_path.as_deref(),
7657            &self.node_id,
7658            &workspaces,
7659            &records,
7660            &managed.records(),
7661            &managed.tombstones(),
7662            &materializations,
7663            &managed_spawn_replays,
7664        );
7665        match &result {
7666            Ok(warning) => self.set_persistence_error(warning.clone()),
7667            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7668        }
7669        result.map(|_| ())
7670    }
7671
7672    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7673        let _mutation_guard = self.mutation_gate.lock().await;
7674        self.begin_shutdown_locked().await
7675    }
7676
7677    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7678        if self.shutdown.swap(true, Ordering::AcqRel) {
7679            return Ok(());
7680        }
7681        SessionLifecycleHook::on_shutdown(self);
7682        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7683        let mut first_error = None;
7684        for session in &self.handle.snapshot().sessions {
7685            let remaining = deadline.saturating_duration_since(Instant::now());
7686            if remaining.is_zero() {
7687                first_error.get_or_insert_with(|| {
7688                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7689                });
7690                break;
7691            }
7692            if let Err(error) = self
7693                .dispatch_bounded(
7694                    ControlCommand::Stop {
7695                        instance_id: session.instance_id,
7696                        force: true,
7697                    },
7698                    remaining,
7699                )
7700                .await
7701            {
7702                first_error.get_or_insert(error);
7703            }
7704        }
7705        self.shutdown_notify.notify_waiters();
7706        match first_error {
7707            Some(error) => Err(error),
7708            None => Ok(()),
7709        }
7710    }
7711
7712    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7713        let bindings = self
7714            .session_bindings
7715            .lock()
7716            .unwrap_or_else(|poisoned| poisoned.into_inner());
7717        if bindings.len() >= CONTROL_SESSIONS_MAX {
7718            Err(failure(
7719                NodeFailureCode::BackendBusy,
7720                "node session-binding capacity is full; remove an existing session first",
7721            ))
7722        } else {
7723            Ok(())
7724        }
7725    }
7726
7727    /// One-owner workspace/env collision guard: a live session on
7728    /// `workspace_id` with a different `environment_profile` (option
7729    /// equality on the resolved receipt, including Some vs None) blocks
7730    /// another spawn on that same cwd. Same profile may still share the
7731    /// workspace; distinct profiles need distinct workspace ids (managed
7732    /// worktree).
7733    fn ensure_workspace_environment_profile_compatible(
7734        &self,
7735        workspace_id: &WorkspaceId,
7736        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7737    ) -> Result<(), NodeFailure> {
7738        let bindings = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner());
7742        for binding in bindings.values() {
7743            if &binding.workspace_id != workspace_id {
7744                continue;
7745            }
7746            if binding.environment_profile.as_ref() != environment_profile {
7747                return Err(failure(
7748                    NodeFailureCode::WorkspaceBusy,
7749                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7750                ));
7751            }
7752        }
7753        Ok(())
7754    }
7755
7756    #[cfg(test)]
7757    fn bind_session(
7758        &self,
7759        instance_id: AgentInstanceId,
7760        workspace_id: WorkspaceId,
7761        generation: SessionGeneration,
7762    ) {
7763        self.bind_session_with_policy(
7764            &SessionAddress {
7765                workspace_id,
7766                session: SessionKey { instance_id, generation },
7767            },
7768            ProviderRuntimePolicy::raw_pty(),
7769            None,
7770        );
7771    }
7772
7773    #[cfg(test)]
7774    fn bind_session_with_policy(
7775        &self,
7776        address: &SessionAddress,
7777        runtime_policy: ProviderRuntimePolicy,
7778        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7779    ) {
7780        self.bind_session_with_materialization(
7781            address,
7782            runtime_policy,
7783            environment_profile,
7784            None,
7785            None,
7786            None,
7787        );
7788    }
7789
7790    fn bind_session_with_materialization(
7791        &self,
7792        address: &SessionAddress,
7793        runtime_policy: ProviderRuntimePolicy,
7794        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795        bundle: Option<ResolvedBundleReceipt>,
7796        context: Option<ResolvedContextPackReceipt>,
7797        materialization_id: Option<MaterializationId>,
7798    ) {
7799        let mut bindings = self
7800            .session_bindings
7801            .lock()
7802            .unwrap_or_else(|poisoned| poisoned.into_inner());
7803        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7804        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7805        bindings.insert(address.session.instance_id, SessionBinding {
7806            workspace_id: address.workspace_id.clone(),
7807            generation: address.session.generation,
7808            runtime_policy,
7809            pending_resume: None,
7810            record_id: None,
7811            managed_worktree_lease_id: None,
7812            environment_profile,
7813            bundle,
7814            context,
7815            materialization_id,
7816        });
7817    }
7818
7819    #[cfg(test)]
7820    fn bind_managed_session(
7821        &self,
7822        address: &SessionAddress,
7823        record_id: SessionRecordId,
7824        runtime_policy: ProviderRuntimePolicy,
7825        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7826    ) {
7827        self.bind_managed_session_with_materialization(
7828            address,
7829            record_id,
7830            runtime_policy,
7831            environment_profile,
7832            None,
7833            None,
7834            None,
7835        );
7836    }
7837
7838    fn bind_managed_session_with_materialization(
7839        &self,
7840        address: &SessionAddress,
7841        record_id: SessionRecordId,
7842        runtime_policy: ProviderRuntimePolicy,
7843        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7844        bundle: Option<ResolvedBundleReceipt>,
7845        context: Option<ResolvedContextPackReceipt>,
7846        materialization_id: Option<MaterializationId>,
7847    ) {
7848        let mut bindings = self
7849            .session_bindings
7850            .lock()
7851            .unwrap_or_else(|poisoned| poisoned.into_inner());
7852        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7853        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7854        bindings.insert(
7855            address.session.instance_id,
7856            SessionBinding {
7857                workspace_id: address.workspace_id.clone(),
7858                generation: address.session.generation,
7859                runtime_policy,
7860                pending_resume: None,
7861                record_id: Some(record_id),
7862                managed_worktree_lease_id: None,
7863                environment_profile,
7864                bundle,
7865                context,
7866                materialization_id,
7867            },
7868        );
7869    }
7870
7871    #[cfg(test)]
7872    fn bind_spawn_session(
7873        &self,
7874        address: &SessionAddress,
7875        provider: AgentId,
7876        mode: SessionMode,
7877        runtime_policy: ProviderRuntimePolicy,
7878        record_policy: SpawnRecordPolicy,
7879        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7881        self.bind_spawn_session_with_materialization(
7882            address,
7883            provider,
7884            mode,
7885            runtime_policy,
7886            record_policy,
7887            environment_profile,
7888            None,
7889            None,
7890            None,
7891        )
7892    }
7893
7894    fn bind_spawn_session_with_materialization(
7895        &self,
7896        address: &SessionAddress,
7897        provider: AgentId,
7898        mode: SessionMode,
7899        runtime_policy: ProviderRuntimePolicy,
7900        record_policy: SpawnRecordPolicy,
7901        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7902        bundle: Option<ResolvedBundleReceipt>,
7903        context: Option<ResolvedContextPackReceipt>,
7904        materialization_id: Option<MaterializationId>,
7905    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7907            && !runtime_policy.provider_session_identity
7908        {
7909            self.bind_session_with_materialization(
7910                address,
7911                runtime_policy,
7912                environment_profile,
7913                bundle,
7914                context,
7915                materialization_id,
7916            );
7917            return Ok(None);
7918        }
7919        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7920        let record = self.new_record(
7921            address,
7922            provider,
7923            mode,
7924            active_record_state(runtime_policy.provider_session_identity, false),
7925            None,
7926            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7927            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7928            record_owns_lifecycle.then(|| context.clone()).flatten(),
7929        )?;
7930        let record_id = record.record_id.clone();
7931        let transaction = self
7932            .state_transaction
7933            .lock()
7934            .unwrap_or_else(|poisoned| poisoned.into_inner());
7935        self.insert_record(record.clone())?;
7936        let previous_materialization = if record_owns_lifecycle {
7937            if let Some(materialization_id) = materialization_id.as_ref() {
7938                let mut materializations = self
7939                    .materializations
7940                    .lock()
7941                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7942                let Some(ownership) = materializations.get_mut(materialization_id) else {
7943                    drop(materializations);
7944                    self.remove_record_memory(&record_id);
7945                    return Err(failure(
7946                        NodeFailureCode::BackendOperationFailed,
7947                        "session environment ownership disappeared before binding",
7948                    ));
7949                };
7950                let previous = ownership.clone();
7951                if ownership
7952                    .transfer_to_record(record_id.clone(), unix_time_ms())
7953                    .is_err()
7954                {
7955                    drop(materializations);
7956                    self.remove_record_memory(&record_id);
7957                    return Err(failure(
7958                        NodeFailureCode::BackendOperationFailed,
7959                        "session environment ownership transfer failed",
7960                    ));
7961                }
7962                Some(previous)
7963            } else {
7964                None
7965            }
7966        } else {
7967            None
7968        };
7969        self.bind_managed_session_with_materialization(
7970            address,
7971            record_id.clone(),
7972            runtime_policy,
7973            environment_profile,
7974            bundle,
7975            context,
7976            materialization_id.clone(),
7977        );
7978        if let Err(error) = self.persist_state_locked() {
7979            self.remove_binding(address);
7980            self.remove_record_memory(&record_id);
7981            if let (Some(materialization_id), Some(previous)) =
7982                (materialization_id.as_ref(), previous_materialization)
7983            {
7984                self.materializations
7985                    .lock()
7986                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7987                    .insert(materialization_id.clone(), previous);
7988            }
7989            return Err(persistence_failure(error));
7990        }
7991        drop(transaction);
7992        self.publish_record(record);
7993        Ok(Some(record_id))
7994    }
7995
7996    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7997        for _ in 0..8 {
7998            let bytes = random_nonce().map_err(|error| {
7999                failure(
8000                    NodeFailureCode::BackendOperationFailed,
8001                    &format!("session record identity generation failed: {error}"),
8002                )
8003            })?;
8004            let mut value = String::with_capacity(27);
8005            value.push_str("sr-");
8006            for byte in &bytes[..12] {
8007                use std::fmt::Write as _;
8008                let _ = write!(&mut value, "{byte:02x}");
8009            }
8010            let record_id = SessionRecordId::new(value).map_err(|error| {
8011                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8012            })?;
8013            let records = self
8014                .session_records
8015                .lock()
8016                .unwrap_or_else(|poisoned| poisoned.into_inner());
8017            if !records.records.contains_key(&record_id) {
8018                return Ok(record_id);
8019            }
8020        }
8021        Err(failure(
8022            NodeFailureCode::BackendBusy,
8023            "could not allocate a unique session record ID",
8024        ))
8025    }
8026
8027    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8028        for _ in 0..8 {
8029            let bytes = random_nonce().map_err(|error| {
8030                failure(
8031                    NodeFailureCode::BackendOperationFailed,
8032                    &format!("task identity generation failed: {error}"),
8033                )
8034            })?;
8035            let mut nonce = [0_u8; 12];
8036            nonce.copy_from_slice(&bytes[..12]);
8037            let task_id = TaskId::from_nonce(nonce);
8038            let records = self
8039                .session_records
8040                .lock()
8041                .unwrap_or_else(|poisoned| poisoned.into_inner());
8042            if !records.records.values().any(|record| {
8043                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8044                    == Some(&task_id)
8045            }) {
8046                return Ok(task_id);
8047            }
8048        }
8049        Err(failure(
8050            NodeFailureCode::BackendBusy,
8051            "could not allocate a unique task ID",
8052        ))
8053    }
8054
8055    fn default_record_name(&self, provider: &AgentId) -> String {
8056        let records = self
8057            .session_records
8058            .lock()
8059            .unwrap_or_else(|poisoned| poisoned.into_inner());
8060        let ordinal = records
8061            .records
8062            .values()
8063            .filter(|record| &record.provider == provider)
8064            .count()
8065            .saturating_add(1);
8066        format!("{} #{ordinal}", provider.as_str())
8067    }
8068
8069    fn new_record(
8070        &self,
8071        address: &SessionAddress,
8072        provider: AgentId,
8073        mode: SessionMode,
8074        state: ManagedSessionState,
8075        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8076        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8077        bundle: Option<ResolvedBundleReceipt>,
8078        context: Option<ResolvedContextPackReceipt>,
8079    ) -> Result<ManagedSessionRecord, NodeFailure> {
8080        let canonical_root = self.workspace_root(&address.workspace_id)?;
8081        let now = unix_time_ms();
8082        Ok(ManagedSessionRecord {
8083            record_id: self.allocate_record_id()?,
8084            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8085            provider,
8086            mode,
8087            state,
8088            workspace_id: address.workspace_id.clone(),
8089            canonical_root: opaque_windows_path(canonical_root),
8090            provider_session,
8091            active_session: Some(address.clone()),
8092            environment_profile,
8093            bundle,
8094            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8095            context,
8096            exported_context: None,
8097            task_binding: None,
8098            created_at_unix_ms: now,
8099            updated_at_unix_ms: now,
8100            last_error: None,
8101        })
8102    }
8103
8104    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8105        let mut records = self
8106            .session_records
8107            .lock()
8108            .unwrap_or_else(|poisoned| poisoned.into_inner());
8109        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8110            return Err(failure(
8111                NodeFailureCode::BackendBusy,
8112                "managed session record capacity is full; forget an inactive record first",
8113            ));
8114        }
8115        if let Some(identity) = record.provider_session.as_ref() {
8116            if records.records.values().any(|candidate| {
8117                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8118                    session_registry::same_provider_session(
8119                        &candidate.provider,
8120                        candidate_identity,
8121                        &record.provider,
8122                        identity,
8123                    )
8124                })
8125            }) {
8126                return Err(failure(
8127                    NodeFailureCode::SessionRecordConflict,
8128                    "provider session is already represented by another managed record",
8129                ));
8130            }
8131        }
8132        match records.records.entry(record.record_id.clone()) {
8133            std::collections::btree_map::Entry::Vacant(entry) => {
8134                entry.insert(record);
8135            }
8136            std::collections::btree_map::Entry::Occupied(_) => {
8137                return Err(failure(
8138                    NodeFailureCode::SessionRecordConflict,
8139                    "session record ID collision",
8140                ));
8141            }
8142        }
8143        Ok(())
8144    }
8145
8146    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8147        self.session_records
8148            .lock()
8149            .unwrap_or_else(|poisoned| poisoned.into_inner())
8150            .records
8151            .get(record_id)
8152            .cloned()
8153            .ok_or_else(|| {
8154                failure(
8155                    NodeFailureCode::UnknownSessionRecord,
8156                    "managed session record does not exist",
8157                )
8158            })
8159    }
8160
8161    fn revalidate_session_record_preview(
8162        &self,
8163        expected: &ManagedSessionRecord,
8164        identity: &ProviderSessionIdentity,
8165    ) -> Result<(), NodeFailure> {
8166        let current = self.record(&expected.record_id)?;
8167        let current_root = self.workspace_root(&current.workspace_id)?;
8168        if current.provider != expected.provider
8169            || current.workspace_id != expected.workspace_id
8170            || current.canonical_root != expected.canonical_root
8171            || current.provider_session.as_ref() != Some(identity)
8172            || !platform::roots_equal(
8173                &current_root,
8174                windows_path_text(&current.canonical_root),
8175            )
8176        {
8177            return Err(failure(
8178                NodeFailureCode::SessionRecordConflict,
8179                "managed session changed while its preview was loading",
8180            ));
8181        }
8182        Ok(())
8183    }
8184
8185    fn revalidate_session_record_context_export(
8186        &self,
8187        expected: &ManagedSessionRecord,
8188        identity: Option<&ProviderSessionIdentity>,
8189        session: &SessionAddress,
8190        allow_clean_detachment: bool,
8191    ) -> Result<(), NodeFailure> {
8192        let current = self.record(&expected.record_id)?;
8193        let current_root = self.workspace_root(&current.workspace_id)?;
8194        let snapshot = self.internal_session_snapshot(session)?;
8195        let binding = self
8196            .session_bindings
8197            .lock()
8198            .unwrap_or_else(|poisoned| poisoned.into_inner())
8199            .get(&session.session.instance_id)
8200            .cloned()
8201            .ok_or_else(|| {
8202                failure(
8203                    NodeFailureCode::SessionRecordConflict,
8204                    "managed session runtime binding disappeared during context export",
8205                )
8206            })?;
8207        if !session_record_context_export_binding_is_exact(
8208            expected,
8209            &current,
8210            identity,
8211            session,
8212            &binding,
8213            &snapshot.agent_id,
8214            &current_root,
8215            allow_clean_detachment,
8216        ) || !session_record_context_export_source_is_usable(
8217            &current,
8218            &snapshot.status,
8219            allow_clean_detachment,
8220        ) {
8221            return Err(failure(
8222                NodeFailureCode::SessionRecordConflict,
8223                "managed session binding changed during context export",
8224            ));
8225        }
8226        Ok(())
8227    }
8228
8229    fn revalidate_loaded_history(
8230        &self,
8231        session: &SessionAddress,
8232        candidate_id: &str,
8233        loaded: &HistorySessionRecord,
8234    ) -> Result<(), NodeFailure> {
8235        let snapshot = self.internal_session_snapshot(session)?;
8236        if snapshot.history.pending.is_some()
8237            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8238            || snapshot.history.loaded.as_ref() != Some(loaded)
8239        {
8240            return Err(failure(
8241                NodeFailureCode::SessionRecordConflict,
8242                "managed session history changed during context export",
8243            ));
8244        }
8245        Ok(())
8246    }
8247
8248    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8249        self.session_records
8250            .lock()
8251            .unwrap_or_else(|poisoned| poisoned.into_inner())
8252            .records
8253            .remove(record_id)
8254    }
8255
8256    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8257        if self.destroy_record_materialization(record_id)? {
8258            self.publish(NodeEvent::SessionRecordRemoved {
8259                record_id: record_id.clone(),
8260            });
8261            return Ok(());
8262        }
8263        let transaction = self
8264            .state_transaction
8265            .lock()
8266            .unwrap_or_else(|poisoned| poisoned.into_inner());
8267        let Some(removed) = self.remove_record_memory(record_id) else {
8268            return Ok(());
8269        };
8270        if let Err(error) = self.persist_state_locked() {
8271            let _ = self.insert_record(removed);
8272            return Err(persistence_failure(error));
8273        }
8274        drop(transaction);
8275        self.publish(NodeEvent::SessionRecordRemoved {
8276            record_id: record_id.clone(),
8277        });
8278        Ok(())
8279    }
8280
8281    fn publish_record(&self, record: ManagedSessionRecord) {
8282        self.publish(NodeEvent::SessionRecordUpserted { record });
8283    }
8284
8285    fn mark_record_error(
8286        &self,
8287        record_id: &SessionRecordId,
8288        message: &str,
8289    ) -> Result<(), NodeFailure> {
8290        let transaction = self
8291            .state_transaction
8292            .lock()
8293            .unwrap_or_else(|poisoned| poisoned.into_inner());
8294        let previous = {
8295            let mut records = self
8296                .session_records
8297                .lock()
8298                .unwrap_or_else(|poisoned| poisoned.into_inner());
8299            let Some(record) = records.records.get_mut(record_id) else {
8300                return Ok(());
8301            };
8302            let previous = record.clone();
8303            record.state = ManagedSessionState::Unavailable;
8304            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8305            record.updated_at_unix_ms = unix_time_ms();
8306            previous
8307        };
8308        if let Err(error) = self.persist_state_locked() {
8309            self.session_records
8310                .lock()
8311                .unwrap_or_else(|poisoned| poisoned.into_inner())
8312                .records
8313                .insert(record_id.clone(), previous);
8314            return Err(persistence_failure(error));
8315        }
8316        let record = self.record(record_id)?;
8317        drop(transaction);
8318        self.publish_record(record);
8319        Ok(())
8320    }
8321
8322    fn rename_session_record(
8323        &self,
8324        record_id: &SessionRecordId,
8325        display_name: String,
8326    ) -> Result<ManagedSessionRecord, NodeFailure> {
8327        validate_display_name(&display_name)
8328            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8329        let _transaction = self
8330            .state_transaction
8331            .lock()
8332            .unwrap_or_else(|poisoned| poisoned.into_inner());
8333        let previous = {
8334            let mut records = self
8335                .session_records
8336                .lock()
8337                .unwrap_or_else(|poisoned| poisoned.into_inner());
8338            let record = records.records.get_mut(record_id).ok_or_else(|| {
8339                failure(
8340                    NodeFailureCode::UnknownSessionRecord,
8341                    "managed session record does not exist",
8342                )
8343            })?;
8344            let previous = record.clone();
8345            record.display_name = display_name;
8346            record.updated_at_unix_ms = unix_time_ms();
8347            previous
8348        };
8349        if let Err(error) = self.persist_state_locked() {
8350            self.session_records
8351                .lock()
8352                .unwrap_or_else(|poisoned| poisoned.into_inner())
8353                .records
8354                .insert(record_id.clone(), previous);
8355            return Err(persistence_failure(error));
8356        }
8357        let updated = self.record(record_id)?;
8358        self.publish_record(updated.clone());
8359        Ok(updated)
8360    }
8361
8362    fn set_session_task(
8363        &self,
8364        record_id: &SessionRecordId,
8365        expected_revision: u64,
8366        target: SessionTaskTargetV1,
8367    ) -> Result<ManagedSessionRecord, NodeFailure> {
8368        let transaction = self
8369            .state_transaction
8370            .lock()
8371            .unwrap_or_else(|poisoned| poisoned.into_inner());
8372        let current = self.record(record_id)?;
8373        let current_task_id = current
8374            .task_binding
8375            .as_ref()
8376            .and_then(|binding| binding.task_id.as_ref());
8377        let current_revision = current
8378            .task_binding
8379            .as_ref()
8380            .map_or(0, |binding| binding.revision);
8381        if expected_revision != current_revision {
8382            return Err(failure(
8383                NodeFailureCode::SessionRecordConflict,
8384                "managed session task binding revision changed",
8385            ));
8386        }
8387        let exact = match &target {
8388            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8389            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8390            SessionTaskTargetV1::New => false,
8391        };
8392        if exact {
8393            return Ok(current);
8394        }
8395        let revision = current_revision.checked_add(1).ok_or_else(|| {
8396            failure(
8397                NodeFailureCode::SessionRecordConflict,
8398                "managed session task binding revision is exhausted",
8399            )
8400        })?;
8401        let task_id = match target {
8402            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8403            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8404            SessionTaskTargetV1::Clear => None,
8405        };
8406        let changed_at_unix_ms = unix_time_ms()
8407            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8408                failure(
8409                    NodeFailureCode::SessionRecordConflict,
8410                    "managed session record timestamp is exhausted",
8411                )
8412            })?);
8413        let updated = {
8414            let mut records = self
8415                .session_records
8416                .lock()
8417                .unwrap_or_else(|poisoned| poisoned.into_inner());
8418            let record = records.records.get_mut(record_id).ok_or_else(|| {
8419                failure(
8420                    NodeFailureCode::UnknownSessionRecord,
8421                    "managed session record does not exist",
8422                )
8423            })?;
8424            record.task_binding = Some(SessionTaskBindingV1 {
8425                revision,
8426                task_id,
8427                changed_at_unix_ms,
8428            });
8429            record.updated_at_unix_ms = changed_at_unix_ms;
8430            record.clone()
8431        };
8432        if let Err(error) = self.persist_state_locked() {
8433            self.session_records
8434                .lock()
8435                .unwrap_or_else(|poisoned| poisoned.into_inner())
8436                .records
8437                .insert(record_id.clone(), current);
8438            return Err(persistence_failure(error));
8439        }
8440        drop(transaction);
8441        self.publish_record(updated.clone());
8442        Ok(updated)
8443    }
8444
8445    fn index_provider_session(
8446        &self,
8447        workspace_id: WorkspaceId,
8448        provider: AgentId,
8449        identity: ProviderSessionIdentity,
8450        display_name: String,
8451    ) -> Result<ManagedSessionRecord, NodeFailure> {
8452        self.index_provider_session_with_policy(
8453            workspace_id,
8454            provider,
8455            identity,
8456            display_name,
8457            false,
8458        )
8459    }
8460
8461    fn index_provider_session_with_policy(
8462        &self,
8463        workspace_id: WorkspaceId,
8464        provider: AgentId,
8465        identity: ProviderSessionIdentity,
8466        display_name: String,
8467        allow_validated_transcript_path: bool,
8468    ) -> Result<ManagedSessionRecord, NodeFailure> {
8469        validate_display_name(&display_name)
8470            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8471        identity
8472            .validate()
8473            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8474        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8475            return Err(failure(
8476                NodeFailureCode::InvalidRequest,
8477                "provider session reference index v1 does not accept transcript paths",
8478            ));
8479        }
8480        let canonical_root = self.workspace_root(&workspace_id)?;
8481        if !self.enabled_providers.contains(&provider) {
8482            return Err(failure(
8483                NodeFailureCode::InvalidRequest,
8484                "provider is not enabled on this node",
8485            ));
8486        }
8487
8488        let transaction = self
8489            .state_transaction
8490            .lock()
8491            .unwrap_or_else(|poisoned| poisoned.into_inner());
8492        {
8493            let records = self
8494                .session_records
8495                .lock()
8496                .unwrap_or_else(|poisoned| poisoned.into_inner());
8497            if let Some(existing) = records.records.values().find(|candidate| {
8498                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8499                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8500                })
8501            }) {
8502                if existing.provider == provider && existing.workspace_id == workspace_id {
8503                    return Ok(existing.clone());
8504                }
8505                return Err(failure(
8506                    NodeFailureCode::SessionRecordConflict,
8507                    "provider session reference conflicts with another provider or workspace",
8508                ));
8509            }
8510            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8511                return Err(failure(
8512                    NodeFailureCode::BackendBusy,
8513                    "managed session record capacity is full; forget an inactive record first",
8514                ));
8515            }
8516        }
8517
8518        let now = unix_time_ms();
8519        let record = ManagedSessionRecord {
8520            record_id: self.allocate_record_id()?,
8521            display_name,
8522            provider,
8523            mode: SessionMode::Pty,
8524            state: ManagedSessionState::Dormant,
8525            workspace_id,
8526            canonical_root: opaque_windows_path(canonical_root),
8527            provider_session: Some(identity),
8528            active_session: None,
8529            environment_profile: None,
8530            bundle: None,
8531            context_id: None,
8532            context: None,
8533            exported_context: None,
8534            task_binding: None,
8535            created_at_unix_ms: now,
8536            updated_at_unix_ms: now,
8537            last_error: None,
8538        };
8539        self.insert_record(record.clone())?;
8540        if let Err(error) = self.persist_state_locked() {
8541            self.remove_record_memory(&record.record_id);
8542            return Err(persistence_failure(error));
8543        }
8544        drop(transaction);
8545        self.publish_record(record.clone());
8546        Ok(record)
8547    }
8548
8549    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8550        self.session_bindings
8551            .lock()
8552            .unwrap_or_else(|poisoned| poisoned.into_inner())
8553            .iter()
8554            .find_map(|(instance_id, binding)| {
8555                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8556                    workspace_id: binding.workspace_id.clone(),
8557                    session: SessionKey {
8558                        instance_id: *instance_id,
8559                        generation: binding.generation,
8560                    },
8561                })
8562            })
8563    }
8564
8565    async fn forget_session_record(
8566        &self,
8567        record_id: &SessionRecordId,
8568    ) -> Result<(), NodeFailure> {
8569        let record = self.record(record_id)?;
8570        if record.active_session.is_some()
8571            || matches!(
8572                record.state,
8573                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8574            )
8575        {
8576            return Err(failure(
8577                NodeFailureCode::SessionRecordBusy,
8578                "stop the managed session before forgetting it",
8579            ));
8580        }
8581        if let Some(address) = self.bound_address_for_record(record_id) {
8582            self.remove_session(&address).await?;
8583        }
8584        self.remove_managed_session_record(record_id, &record.workspace_id).await
8585    }
8586
8587    /// Removes one managed session record from durable state and notifies
8588    /// every listener -- the shared transaction body behind both
8589    /// `forget_session_record`'s operator-driven path (busy-checked above
8590    /// it, and clears any still-live session binding before calling in
8591    /// here) and the retention sweep (`retire_unavailable_session_records`,
8592    /// which only ever selects `ManagedSessionState::Unavailable` records
8593    /// that are already known never-busy and never bound, so it calls
8594    /// straight into this with neither check). Cleans a managed-worktree
8595    /// lease if the record held one, removes the record's environment
8596    /// materialization (or, absent one, removes it from memory directly),
8597    /// persists under `state_transaction` with rollback on persist failure,
8598    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8599    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8600    /// exactly as it does for an operator-driven forget.
8601    async fn remove_managed_session_record(
8602        &self,
8603        record_id: &SessionRecordId,
8604        workspace_id: &WorkspaceId,
8605    ) -> Result<(), NodeFailure> {
8606        let managed_lease_id = self.managed_worktrees.lock()
8607            .unwrap_or_else(|poisoned| poisoned.into_inner())
8608            .lease_for_workspace(workspace_id);
8609        if !self.destroy_record_materialization(record_id)? {
8610            let transaction = self
8611                .state_transaction
8612                .lock()
8613                .unwrap_or_else(|poisoned| poisoned.into_inner());
8614            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8615                failure(
8616                    NodeFailureCode::UnknownSessionRecord,
8617                    "managed session record does not exist",
8618                )
8619            })?;
8620            if let Err(error) = self.persist_state_locked() {
8621                let _ = self.insert_record(removed);
8622                return Err(persistence_failure(error));
8623            }
8624            drop(transaction);
8625        }
8626        self.publish(NodeEvent::SessionRecordRemoved {
8627            record_id: record_id.clone(),
8628        });
8629        if let Some(lease_id) = managed_lease_id {
8630            if let Some(snapshot) = self.managed_worktrees.lock()
8631                .unwrap_or_else(|poisoned| poisoned.into_inner())
8632                .get(&lease_id)
8633                .map(ManagedWorktreeLeaseRecord::snapshot)
8634            {
8635                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8636            }
8637            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8638        }
8639        Ok(())
8640    }
8641
8642    /// Retires `ManagedSessionState::Unavailable` records against the
8643    /// node's configured `session_record_retention` age/keep-N policy.
8644    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8645    /// `Dormant` is resumable and folding it into this sweep would delete
8646    /// records a caller can still act on. Both policy fields default to
8647    /// `0` (disabled); when both are `0` this is a no-op. When both are
8648    /// set, a record is retired only if it fails BOTH tests (older than
8649    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8650    /// workspace); when only one is set, that one alone decides. Called
8651    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8652    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8653    /// loop -- see both call sites.
8654    async fn retire_unavailable_session_records(&self) {
8655        let policy = self.session_record_retention;
8656        let age_enabled = policy.age_ms > 0;
8657        let keep_enabled = policy.keep_per_workspace > 0;
8658        if !age_enabled && !keep_enabled {
8659            return;
8660        }
8661        let now = unix_time_ms();
8662        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8663            self.session_records
8664                .lock()
8665                .unwrap_or_else(|poisoned| poisoned.into_inner())
8666                .records
8667                .values()
8668                .filter(|record| record.state == ManagedSessionState::Unavailable)
8669                .map(|record| {
8670                    (
8671                        record.record_id.clone(),
8672                        record.workspace_id.clone(),
8673                        record.provider.clone(),
8674                        record.updated_at_unix_ms,
8675                    )
8676                })
8677                .collect()
8678        };
8679        if candidates.is_empty() {
8680            return;
8681        }
8682        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8683            BTreeMap::new();
8684        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8685            by_workspace
8686                .entry(workspace_id)
8687                .or_default()
8688                .push((record_id, provider, updated_at_unix_ms));
8689        }
8690        let reason = if age_enabled && keep_enabled {
8691            "age+keep-n"
8692        } else if age_enabled {
8693            "age"
8694        } else {
8695            "keep-n"
8696        };
8697        let mut retired: u64 = 0;
8698        for (workspace_id, mut records) in by_workspace {
8699            // Newest `updated_at_unix_ms` first, so index `>=
8700            // keep_per_workspace` names exactly "beyond the newest N".
8701            records.sort_by(|left, right| right.2.cmp(&left.2));
8702            for (index, (record_id, provider, updated_at_unix_ms)) in
8703                records.into_iter().enumerate()
8704            {
8705                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8706                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8707                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8708                let should_retire = if age_enabled && keep_enabled {
8709                    age_hit && keep_hit
8710                } else {
8711                    age_hit || keep_hit
8712                };
8713                if !should_retire {
8714                    continue;
8715                }
8716                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8717                    Ok(()) => {
8718                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8719                        retired += 1;
8720                        tracing::info!(
8721                            node_id = %self.node_id,
8722                            record_id = %record_id,
8723                            workspace_id = %workspace_id,
8724                            provider = %provider,
8725                            age_ms = record_age_ms,
8726                            reason,
8727                            "managed session record retired"
8728                        );
8729                    }
8730                    Err(error) => tracing::warn!(
8731                        node_id = %self.node_id,
8732                        record_id = %record_id,
8733                        workspace_id = %workspace_id,
8734                        code = ?error.code,
8735                        cause = %error.message,
8736                        "session record retention sweep failed to retire a record"
8737                    ),
8738                }
8739            }
8740        }
8741        if retired > 0 {
8742            let remaining_unavailable = self
8743                .session_records
8744                .lock()
8745                .unwrap_or_else(|poisoned| poisoned.into_inner())
8746                .records
8747                .values()
8748                .filter(|record| record.state == ManagedSessionState::Unavailable)
8749                .count();
8750            tracing::info!(
8751                node_id = %self.node_id,
8752                retired,
8753                remaining_unavailable,
8754                "session record retention sweep completed"
8755            );
8756        }
8757    }
8758
8759    async fn resume_session_record(
8760        &self,
8761        record_id: &SessionRecordId,
8762        terminal_size: gate4agent_types::TerminalSize,
8763        initial_prompt: Option<String>,
8764    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8765        let mut record = self.record(record_id)?;
8766        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8767        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8768        if record.mode == SessionMode::Acp
8769            && record.provider.as_str() == "claude"
8770            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8771        {
8772            record.mode = SessionMode::Pty;
8773            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8774                r.mode = SessionMode::Pty;
8775            }
8776            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8777        }
8778        if record.active_session.is_some()
8779            || matches!(
8780                record.state,
8781                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8782            )
8783        {
8784            return Err(failure(
8785                NodeFailureCode::SessionRecordBusy,
8786                "managed session already has a live runtime binding",
8787            ));
8788        }
8789        let identity = record.provider_session.clone().ok_or_else(|| {
8790            failure(
8791                NodeFailureCode::SessionRecordNotResumable,
8792                "managed session has no verified provider session identity",
8793            )
8794        })?;
8795        let working_directory = self.workspace_root(&record.workspace_id)?;
8796        if !platform::roots_equal(
8797            &working_directory,
8798            windows_path_text(&record.canonical_root),
8799        ) {
8800            return Err(failure(
8801                NodeFailureCode::SessionRecordConflict,
8802                "managed session workspace root changed; refusing to resume in another directory",
8803            ));
8804        }
8805        {
8806            let records = self
8807                .session_records
8808                .lock()
8809                .unwrap_or_else(|poisoned| poisoned.into_inner());
8810            if records.records.values().any(|candidate| {
8811                candidate.record_id != *record_id
8812                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8813                        session_registry::same_provider_session(
8814                            &candidate.provider,
8815                            candidate_identity,
8816                            &record.provider,
8817                            &identity,
8818                        )
8819                    })
8820                    && (candidate.active_session.is_some()
8821                        || matches!(
8822                            candidate.state,
8823                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8824                        ))
8825            }) {
8826                return Err(failure(
8827                    NodeFailureCode::SessionRecordConflict,
8828                    "provider session is already active through another managed record",
8829                ));
8830            }
8831        }
8832        let runtime_requirement = if initial_prompt.is_some() {
8833            ProviderRuntimeRequirement::ResumeWithPrompt
8834        } else {
8835            ProviderRuntimeRequirement::Resume
8836        };
8837        let runtime_policy = self.admit_provider_runtime(
8838            &record.provider,
8839            runtime_requirement,
8840        )
8841        .await?;
8842        // The resumed runtime runs in the session folder its record was named after.
8843        let working_directory = match self.m4a_accounts.as_ref() {
8844            Some(_) => {
8845                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8846                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8847            }
8848            None => working_directory,
8849        };
8850        let request = ResumeLaunchRequest {
8851            working_directory,
8852            terminal_size,
8853            initial_prompt,
8854        };
8855        request
8856            .validate()
8857            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8858        self.ensure_binding_capacity()?;
8859        let resolved_materialization = match self.resolve_record_materialization(
8860            record_id,
8861            &record.provider,
8862            record.mode,
8863            record.environment_profile.as_ref(),
8864            record.bundle.as_ref(),
8865            record.context.as_ref(),
8866        ) {
8867            Ok(materialization) => materialization,
8868            Err(error) => {
8869                let label = match error.code {
8870                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8871                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8872                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8873                    _ => "environment-profile-unavailable",
8874                };
8875                self.mark_record_error(record_id, label)?;
8876                return Err(error);
8877            }
8878        };
8879        let (materialization_id, environment_overlay) = match resolved_materialization {
8880            Some((id, overlay)) => (Some(id), overlay),
8881            None => (None, None),
8882        };
8883        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8884        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8885        let environment_overlay = {
8886            let mut args: Vec<OsString> = Vec::new();
8887            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8888                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8889                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8890                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8891            }
8892            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8893            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8894            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8895                if let Some(sel) = options.as_ref() {
8896                    args.push("--model".into()); args.push(sel.model.clone().into());
8897                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8898                        args.push("--effort".into()); args.push(effort.to_owned().into());
8899                    }
8900                }
8901            }
8902            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8903                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8904                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8905                        instance_id: instance_id.0,
8906                        provider: record.provider.as_str().to_owned(),
8907                        workspace_id: record.workspace_id.as_str().to_owned(),
8908                        provider_session_id: identity.id.clone(),
8909                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8910                        cwd: String::new(),
8911                        acp: record.mode == SessionMode::Acp,
8912                        options,
8913                        cols: 0,
8914                        rows: 0,
8915                    });
8916                }
8917            }
8918            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8919        };
8920        // The resumed runtime keeps the m4a account of the runtime it replaces.
8921        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8922            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8923                if let Some(old) = self.bound_address_for_record(record_id) {
8924                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8925                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8926                    }
8927                }
8928                let session_key = self.m4a_session_key(instance_id);
8929                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8930            }
8931            _ => environment_overlay,
8932        };
8933        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8934            instance_id,
8935            &record.provider,
8936            record.mode,
8937            record.environment_profile.as_ref(),
8938        ) {
8939            Ok(selection) => selection,
8940            Err(error) => {
8941                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8942                return Err(error);
8943            }
8944        };
8945        let mut instance_overlay = environment_overlay
8946            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8947            .transpose()?
8948            .flatten();
8949        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8950            self.remove_session(&stale_address).await?;
8951        }
8952        let address = SessionAddress {
8953            workspace_id: record.workspace_id.clone(),
8954            session: SessionKey {
8955                instance_id,
8956                generation: SessionGeneration(1),
8957            },
8958        };
8959        let transaction = self
8960            .state_transaction
8961            .lock()
8962            .unwrap_or_else(|poisoned| poisoned.into_inner());
8963        let current = self.record(record_id)?;
8964        if current.active_session.is_some()
8965            || matches!(
8966                current.state,
8967                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8968            )
8969        {
8970            return Err(failure(
8971                NodeFailureCode::SessionRecordBusy,
8972                "managed session became active while resume was being prepared",
8973            ));
8974        }
8975        self.bind_managed_session_with_materialization(
8976            &address,
8977            record_id.clone(),
8978            runtime_policy,
8979            record.environment_profile.clone(),
8980            record.bundle.clone(),
8981            record.context.clone(),
8982            materialization_id,
8983        );
8984        if let Some(selection) = environment_selection.take() {
8985            selection.retain();
8986        }
8987        if let Some(overlay) = instance_overlay.take() {
8988            overlay.retain();
8989        }
8990        drop(transaction);
8991
8992        let transport = match record.mode {
8993            SessionMode::Pty => TransportKind::Pty,
8994            SessionMode::Inline => TransportKind::Pipe,
8995            SessionMode::Acp => TransportKind::Acp,
8996        };
8997        let agent_id = record.provider.clone();
8998        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8999        if let Err(error) = self
9000            .dispatch_bounded(
9001                ControlCommand::Register {
9002                    instance_id,
9003                    agent_id,
9004                    transport,
9005                },
9006                dispatch_timeout,
9007            )
9008            .await
9009        {
9010            if let Some(binding) = self.remove_binding(&address) {
9011                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9012            }
9013            return Err(error);
9014        }
9015        let command = self.prepare_command(ControlCommand::Resume {
9016            instance_id,
9017            target: ResumeTarget::ProviderSession { identity },
9018            runtime_policy,
9019            request,
9020        });
9021        let started_after = self.current_sequence();
9022        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9023            self.rollback_spawn(&address, dispatch_timeout).await?;
9024            return Err(error);
9025        }
9026        if let Err(error) = self.dispatch_envelope(command) {
9027            self.clear_armed_resume(&address);
9028            self.rollback_spawn(&address, dispatch_timeout).await?;
9029            return Err(error);
9030        }
9031        match self
9032            .wait_for_record_resume(&address, started_after)
9033            .await
9034        {
9035            Ok(settled_address) => {
9036                let managed_lease_id = {
9037                    self.managed_worktrees.lock()
9038                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9039                        .lease_for_workspace(&record.workspace_id)
9040                };
9041                if let Some(lease_id) = managed_lease_id {
9042                    {
9043                        let mut bindings = self.session_bindings.lock()
9044                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9045                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9046                            .ok_or_else(|| failure(
9047                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9048                                "resumed managed session lost its exact binding",
9049                            ))?;
9050                        if binding.generation != settled_address.session.generation {
9051                            return Err(failure(
9052                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9053                                "resumed managed session generation diverged",
9054                            ));
9055                        }
9056                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9057                    }
9058                    let snapshot = self.managed_worktrees.lock()
9059                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9060                        .bind_session(
9061                            &lease_id,
9062                            ManagedWorktreeSessionHolder {
9063                                incarnation_id: self.incarnation_id,
9064                                instance_id: settled_address.session.instance_id,
9065                                generation: settled_address.session.generation,
9066                            },
9067                            Some(record_id.clone()),
9068                            unix_time_ms(),
9069                        ).map_err(|message| failure(
9070                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071                            &message,
9072                        ))?;
9073                    if let Err(error) = self.persist_state() {
9074                        let _ = self.remove_session(&settled_address).await;
9075                        return Err(persistence_failure(error));
9076                    }
9077                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9078                }
9079                Ok((self.record(record_id)?, settled_address))
9080            }
9081            Err(error) => {
9082                if matches!(
9083                    error.code,
9084                    NodeFailureCode::SessionRecordNotResumable
9085                        | NodeFailureCode::BackendOperationFailed
9086                ) {
9087                    if let Some(bound) = self.bound_address_for_record(record_id) {
9088                        let _ = self.remove_session(&bound).await;
9089                    }
9090                }
9091                Err(error)
9092            }
9093        }
9094    }
9095
9096    async fn wait_for_record_resume(
9097        &self,
9098        initial_address: &SessionAddress,
9099        after_sequence: u64,
9100    ) -> Result<SessionAddress, NodeFailure> {
9101        let deadline = Instant::now()
9102            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9103        let mut scan_after = after_sequence;
9104        loop {
9105            let events = self
9106                .history
9107                .lock()
9108                .unwrap_or_else(|poisoned| poisoned.into_inner())
9109                .events
9110                .iter()
9111                .filter(|event| event.sequence > scan_after)
9112                .cloned()
9113                .collect::<Vec<_>>();
9114            for envelope in events {
9115                scan_after = scan_after.max(envelope.sequence);
9116                let NodeEvent::Control { address, event } = envelope.event else {
9117                    continue;
9118                };
9119                if address.session.instance_id != initial_address.session.instance_id {
9120                    continue;
9121                }
9122                match event.event {
9123                    ControlEventKind::Resumed { .. } => return Ok(address),
9124                    ControlEventKind::ResumeDenied { reason } => {
9125                        return Err(failure(
9126                            NodeFailureCode::SessionRecordNotResumable,
9127                            &reason,
9128                        ));
9129                    }
9130                    ControlEventKind::ResumeFailed { message }
9131                    | ControlEventKind::CommandRejected { message } => {
9132                        return Err(failure(
9133                            NodeFailureCode::BackendOperationFailed,
9134                            &message,
9135                        ));
9136                    }
9137                    _ => {}
9138                }
9139            }
9140            if Instant::now() >= deadline {
9141                return Err(failure(
9142                    NodeFailureCode::BackendBusy,
9143                    "managed resume did not settle before the bounded deadline",
9144                ));
9145            }
9146            sleep(Duration::from_millis(5)).await;
9147        }
9148    }
9149
9150    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9151        self.workspaces
9152            .read()
9153            .unwrap_or_else(|poisoned| poisoned.into_inner())
9154            .get(workspace_id)
9155            .cloned()
9156            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9157    }
9158
9159    fn workspace_roots(&self) -> Vec<PathBuf> {
9160        self.workspaces
9161            .read()
9162            .unwrap_or_else(|poisoned| poisoned.into_inner())
9163            .values()
9164            .map(PathBuf::from)
9165            .collect()
9166    }
9167
9168    async fn inspect_workspace(
9169        &self,
9170        workspace_id: WorkspaceId,
9171    ) -> Result<WorkspaceInspection, NodeFailure> {
9172        let _permit = self
9173            .inspection_slots
9174            .clone()
9175            .try_acquire_owned()
9176            .map_err(|_| {
9177                tracing::warn!(
9178                    workspace_id = %workspace_id,
9179                    "workspace inspection rejected: inspection capacity is busy",
9180                );
9181                failure(
9182                    NodeFailureCode::BackendBusy,
9183                    "workspace inspection capacity is busy",
9184                )
9185            })?;
9186        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9187            tracing::warn!(
9188                workspace_id = %workspace_id,
9189                code = ?error.code,
9190                "workspace inspection rejected: workspace is not registered",
9191            );
9192            error
9193        })?;
9194        let tree_root = canonical_root.clone();
9195        let started_at = Instant::now();
9196        let time_budget_ms = workspace_inspection_time_budget_ms();
9197        let entry_cap = workspace_inspection_entry_cap();
9198        let deadline = started_at + Duration::from_millis(time_budget_ms);
9199        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9200            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9201        })
9202        .await
9203        .map_err(|error| {
9204            tracing::warn!(
9205                workspace_id = %workspace_id,
9206                error = %error,
9207                "workspace inspection rejected: walk task failed",
9208            );
9209            failure(
9210                NodeFailureCode::BackendOperationFailed,
9211                &format!("workspace inspection task failed: {error}"),
9212            )
9213        })?;
9214        let (mut git, git_time_budget_exceeded) =
9215            inspect_git_workspace(&canonical_root, deadline).await;
9216        if !git.worktrees.is_empty() {
9217            let registered = self
9218                .workspaces
9219                .read()
9220                .unwrap_or_else(|poisoned| poisoned.into_inner())
9221                .iter()
9222                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9223                .collect::<Vec<_>>();
9224            for worktree in &mut git.worktrees {
9225                worktree.workspace_id = registered
9226                    .iter()
9227                    .find(|(_, root)| {
9228                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9229                    })
9230                    .map(|(workspace_id, _)| workspace_id.clone());
9231            }
9232        }
9233        if git.is_repository {
9234            git.managed_worktree = self
9235                .managed_worktrees
9236                .lock()
9237                .unwrap_or_else(|poisoned| poisoned.into_inner())
9238                .git_scope(&workspace_id, git.branch.as_deref());
9239        }
9240        let truncation = workspace_inspection_truncation(
9241            &walk_budget,
9242            git_time_budget_exceeded,
9243            started_at.elapsed(),
9244        );
9245        if let Some(truncation) = &truncation {
9246            tracing::info!(
9247                workspace_id = %workspace_id,
9248                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9249                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9250                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9251                entries_visited = truncation.entries_visited,
9252                elapsed_ms = truncation.elapsed_ms,
9253                "workspace inspection truncated by its inner budget",
9254            );
9255        }
9256        Ok(WorkspaceInspection {
9257            workspace_id,
9258            entries,
9259            tree_truncated,
9260            git,
9261            truncation,
9262        })
9263    }
9264
9265    async fn browse_host_directories(
9266        &self,
9267        directory: Option<OpaqueHostPath>,
9268        after: Option<OpaqueHostPath>,
9269    ) -> Result<HostDirectoryListing, NodeFailure> {
9270        let permit = self
9271            .inspection_slots
9272            .clone()
9273            .try_acquire_owned()
9274            .map_err(|_| {
9275                failure(
9276                    NodeFailureCode::BackendBusy,
9277                    "host directory browse capacity is busy",
9278                )
9279            })?;
9280        let task = tokio::task::spawn_blocking(move || {
9281            let _permit = permit;
9282            browse_host_directories(directory, after)
9283        });
9284        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9285            .await
9286            .map_err(|_| {
9287                failure(
9288                    NodeFailureCode::HostDirectoryReadTimedOut,
9289                    "host directory browse exceeded its bounded deadline",
9290                )
9291            })?
9292            .map_err(|_| {
9293                failure(
9294                    NodeFailureCode::HostDirectoryReadFailed,
9295                    "host directory browse task failed",
9296                )
9297            })?
9298            .map_err(host_directory_failure)
9299    }
9300
9301    async fn read_workspace_file(
9302        &self,
9303        workspace_id: WorkspaceId,
9304        path: RepositoryPath,
9305    ) -> Result<WorkspaceFileRead, NodeFailure> {
9306        let canonical_root = self.workspace_root(&workspace_id)?;
9307        let permit = self
9308            .inspection_slots
9309            .clone()
9310            .try_acquire_owned()
9311            .map_err(|_| {
9312                failure(
9313                    NodeFailureCode::BackendBusy,
9314                    "workspace file read capacity is busy",
9315                )
9316            })?;
9317        let path_to_read = path.clone();
9318        let task = tokio::task::spawn_blocking(move || {
9319            let _permit = permit;
9320            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9321        });
9322        let content = timeout(
9323            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9324            task,
9325        )
9326        .await
9327        .map_err(|_| {
9328            failure(
9329                NodeFailureCode::RepositoryFileReadTimedOut,
9330                "repository file read exceeded its bounded deadline",
9331            )
9332        })?
9333        .map_err(|_| {
9334            failure(
9335                NodeFailureCode::RepositoryFileReadFailed,
9336                "repository file read task failed",
9337            )
9338        })?
9339        .map_err(workspace_file_failure)?;
9340        let content = match content {
9341            WorkspaceFileBytes::Utf8(text) => {
9342                let revision = Some(workspace_file_revision(text.as_bytes()));
9343                return Ok(WorkspaceFileRead {
9344                    workspace_id,
9345                    path,
9346                    content: WorkspaceFileContent::Utf8 {
9347                        byte_len: u32::try_from(text.len())
9348                            .expect("bounded workspace text length must fit u32"),
9349                        text,
9350                    },
9351                    revision,
9352                });
9353            }
9354            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9355                WorkspaceFileContent::NonUtf8 {
9356                    byte_len: u32::try_from(byte_length)
9357                        .expect("bounded workspace file length must fit u32"),
9358                }
9359            }
9360            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9361                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9362            },
9363        };
9364        Ok(WorkspaceFileRead {
9365            workspace_id,
9366            path,
9367            content,
9368            revision: None,
9369        })
9370    }
9371
9372    async fn write_workspace_file(
9373        &self,
9374        workspace_id: WorkspaceId,
9375        path: RepositoryPath,
9376        expected_revision: WorkspaceFileRevision,
9377        text: String,
9378    ) -> Result<WorkspaceFileRead, NodeFailure> {
9379        let canonical_root = self.workspace_root(&workspace_id)?;
9380        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9381            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9382        })?;
9383        let path_to_write = path.clone();
9384        let expected = expected_revision.as_str().to_owned();
9385        let text_to_write = text.clone();
9386        let task = tokio::task::spawn_blocking(move || {
9387            let _permit = permit;
9388            write_workspace_file_to_disk(
9389                Path::new(&canonical_root),
9390                &path_to_write,
9391                &expected,
9392                &text_to_write,
9393            )
9394        });
9395        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9396            .await
9397            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9398            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9399            .map_err(workspace_file_write_failure)?;
9400        Ok(WorkspaceFileRead {
9401            workspace_id,
9402            path,
9403            content: WorkspaceFileContent::Utf8 {
9404                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9405                text,
9406            },
9407            revision: Some(WorkspaceFileRevision::new(revision)
9408                .expect("workspace writer returns a valid SHA-256 revision")),
9409        })
9410    }
9411
9412    async fn create_workspace_file(
9413        &self,
9414        workspace_id: WorkspaceId,
9415        path: RepositoryPath,
9416    ) -> Result<WorkspaceFileRead, NodeFailure> {
9417        let canonical_root = self.workspace_root(&workspace_id)?;
9418        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9420        })?;
9421        let path_to_create = path.clone();
9422        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9423        let task_commit_state = Arc::clone(&commit_state);
9424        let task = tokio::task::spawn_blocking(move || {
9425            let _permit = permit;
9426            create_workspace_file_on_disk(
9427                Path::new(&canonical_root),
9428                &path_to_create,
9429                &task_commit_state,
9430            )
9431        });
9432        let revision = settle_workspace_entry_create(
9433            task,
9434            commit_state,
9435            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9436            "repository file create exceeded its bounded deadline",
9437            "repository file create task failed",
9438        )
9439        .await?;
9440        Ok(WorkspaceFileRead {
9441            workspace_id,
9442            path,
9443            content: WorkspaceFileContent::Utf8 {
9444                text: String::new(),
9445                byte_len: 0,
9446            },
9447            revision: Some(WorkspaceFileRevision::new(revision)
9448                .expect("workspace creator returns a valid SHA-256 revision")),
9449        })
9450    }
9451
9452    async fn create_workspace_directory(
9453        &self,
9454        workspace_id: WorkspaceId,
9455        path: RepositoryPath,
9456    ) -> Result<WorkspaceEntry, NodeFailure> {
9457        let canonical_root = self.workspace_root(&workspace_id)?;
9458        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9459            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9460        })?;
9461        let path_to_create = path.clone();
9462        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9463        let task_commit_state = Arc::clone(&commit_state);
9464        let task = tokio::task::spawn_blocking(move || {
9465            let _permit = permit;
9466            create_workspace_directory_on_disk(
9467                Path::new(&canonical_root),
9468                &path_to_create,
9469                &task_commit_state,
9470            )
9471        });
9472        settle_workspace_entry_create(
9473            task,
9474            commit_state,
9475            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9476            "repository directory create exceeded its bounded deadline",
9477            "repository directory create task failed",
9478        )
9479        .await?;
9480        Ok(WorkspaceEntry {
9481            relative_path: path,
9482            kind: WorkspaceEntryKind::Directory,
9483        })
9484    }
9485
9486    async fn read_git_history(
9487        &self,
9488        workspace_id: WorkspaceId,
9489        path: Option<RepositoryPath>,
9490        before: Option<GitObjectId>,
9491        limit: u16,
9492    ) -> Result<GitHistoryPage, NodeFailure> {
9493        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9494            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9495        })?;
9496        let root = self.workspace_root(&workspace_id)?;
9497        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9498    }
9499
9500    async fn read_git_diff(
9501        &self,
9502        workspace_id: WorkspaceId,
9503        request: GitDiffRequest,
9504    ) -> Result<GitDiff, NodeFailure> {
9505        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9506            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9507        })?;
9508        let root = self.workspace_root(&workspace_id)?;
9509        read_git_diff_bounded(&root, request).await
9510    }
9511
9512    fn native_session_route_root(
9513        &self,
9514        route: &NativeSessionCatalogRoute,
9515    ) -> Result<Option<String>, NodeFailure> {
9516        route
9517            .validate()
9518            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9519        match route.scope {
9520            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9521                .workspace_id
9522                .as_ref()
9523                .map(|workspace_id| self.workspace_root(workspace_id))
9524                .transpose(),
9525            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9526        }
9527    }
9528
9529    fn project_native_session_entries(
9530        &self,
9531        route: &NativeSessionCatalogRoute,
9532        entries: Vec<ScopedNativeSessionCatalogEntry>,
9533    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9534        let current_root = route
9535            .workspace_id
9536            .as_ref()
9537            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9538        let records = self
9539            .session_records
9540            .lock()
9541            .unwrap_or_else(|poisoned| poisoned.into_inner());
9542        let mut seen_records = std::collections::HashSet::new();
9543        entries
9544            .into_iter()
9545            .map(|entry| {
9546                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9547                    let matches = records
9548                        .records
9549                        .values()
9550                        .filter(|record| {
9551                            record.provider == route.provider
9552                                && record.workspace_id == *workspace_id
9553                                && current_root.as_deref().is_some_and(|root| {
9554                                    platform::roots_equal(
9555                                        root,
9556                                        windows_path_text(&record.canonical_root),
9557                                    )
9558                                })
9559                                && record.provider_session.as_ref().is_some_and(|identity| {
9560                                    session_registry::same_provider_session(
9561                                        &record.provider,
9562                                        identity,
9563                                        &route.provider,
9564                                        &entry.provider_session,
9565                                    )
9566                                })
9567                        })
9568                        .map(|record| record.record_id.clone())
9569                        .collect::<Vec<_>>();
9570                    (matches.len() == 1
9571                        && seen_records.insert(matches[0].clone()))
9572                        .then(|| matches[0].clone())
9573                });
9574                let metadata = entry.metadata;
9575                crate::protocol::NativeSessionCatalogEntry {
9576                    selection_id: metadata.selection_id,
9577                    title: metadata.title,
9578                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9579                    model: metadata.model,
9580                    message_count: metadata.message_count,
9581                    completed_turn_count: metadata.completed_turn_count,
9582                    external_group: entry.external_group,
9583                    record_id,
9584                }
9585            })
9586            .collect()
9587    }
9588
9589    async fn catalog_native_sessions(
9590        &self,
9591        route: NativeSessionCatalogRoute,
9592        limit: u16,
9593    ) -> Result<(
9594        Vec<crate::protocol::NativeSessionCatalogEntry>,
9595        crate::protocol::NativeSessionCatalogSummary,
9596    ), NodeFailure> {
9597        let canonical_root = self.native_session_route_root(&route)?;
9598        let registered_workspace_roots = self.workspace_roots();
9599        let provider = route.provider.clone();
9600        let scope = route.scope;
9601        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9602            failure(
9603                NodeFailureCode::InvalidRequest,
9604                "native session catalog is not configured",
9605            )
9606        })?;
9607        let _permit = Arc::clone(&self.inspection_slots)
9608            .try_acquire_owned()
9609            .map_err(|_| {
9610                failure(
9611                    NodeFailureCode::BackendBusy,
9612                    "native session catalog capacity is busy",
9613                )
9614            })?;
9615        let page = run_native_session_catalog_operation(
9616            catalog,
9617            "native session catalog",
9618            move |catalog| {
9619                catalog.catalog_initial_for_scope(
9620                    &provider,
9621                    scope,
9622                    canonical_root.as_deref().map(Path::new),
9623                    &registered_workspace_roots,
9624                    limit,
9625                )
9626            },
9627        )
9628        .await?;
9629        let summary = crate::protocol::NativeSessionCatalogSummary {
9630            catalog_revision: page.revision,
9631            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9632            recent_total_count: page.recent_total_count,
9633            older_total_count: page.older_total_count,
9634            recent_next_after_selection_id: page.next_after_selection_id,
9635            recent_has_more: page.remaining_count > 0,
9636        };
9637        Ok((self.project_native_session_entries(&route, page.entries), summary))
9638    }
9639
9640    async fn page_native_sessions(
9641        &self,
9642        route: NativeSessionCatalogRoute,
9643        window: crate::protocol::NativeSessionCatalogWindow,
9644        catalog_revision: u64,
9645        recent_cutoff_unix_ms: u64,
9646        after_selection_id: Option<String>,
9647        limit: u16,
9648    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9649        let canonical_root = self.native_session_route_root(&route)?;
9650        let registered_workspace_roots = self.workspace_roots();
9651        let provider = route.provider.clone();
9652        let scope = route.scope;
9653        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9654            failure(
9655                NodeFailureCode::InvalidRequest,
9656                "native session catalog is not configured",
9657            )
9658        })?;
9659        let _permit = Arc::clone(&self.inspection_slots)
9660            .try_acquire_owned()
9661            .map_err(|_| {
9662                failure(
9663                    NodeFailureCode::BackendBusy,
9664                    "native session catalog capacity is busy",
9665                )
9666            })?;
9667        let page = run_native_session_catalog_operation(
9668            catalog,
9669            "native session catalog page",
9670            move |catalog| {
9671                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9672                    &provider,
9673                    scope,
9674                    canonical_root.as_deref().map(Path::new),
9675                    &registered_workspace_roots,
9676                    window,
9677                    catalog_revision,
9678                    recent_cutoff_unix_ms,
9679                    after_selection_id.as_deref(),
9680                    limit,
9681                ))
9682            },
9683        )
9684        .await?;
9685        let page = page.map_err(|error| match error {
9686            NativeSessionCatalogError::StaleCatalog => failure(
9687                NodeFailureCode::StaleNativeSessionCatalog,
9688                "native session catalog revision or cursor is stale",
9689            ),
9690            _ => failure(
9691                NodeFailureCode::BackendOperationFailed,
9692                "native session catalog page failed",
9693            ),
9694        })?;
9695        let entries = self.project_native_session_entries(&route, page.entries);
9696        Ok(crate::protocol::NativeSessionCatalogPage {
9697            window: page.window,
9698            revision: page.revision,
9699            entries,
9700            next_after_selection_id: page.next_after_selection_id,
9701            remaining_count: page.remaining_count,
9702            has_more: page.remaining_count > 0,
9703        })
9704    }
9705
9706    async fn preview_native_session(
9707        &self,
9708        selection: NativeSessionSelection,
9709        message_limit: u16,
9710    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9711        selection
9712            .validate()
9713            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9714        let canonical_root = self.native_session_route_root(&selection.route)?;
9715        let registered_workspace_roots = self.workspace_roots();
9716        let provider = selection.route.provider.clone();
9717        let scope = selection.route.scope;
9718        let catalog_revision = selection.catalog_revision;
9719        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9720        let selection_id = selection.selection_id.clone();
9721        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9722            failure(
9723                NodeFailureCode::InvalidRequest,
9724                "native session preview is not configured",
9725            )
9726        })?;
9727        let _permit = Arc::clone(&self.inspection_slots)
9728            .try_acquire_owned()
9729            .map_err(|_| {
9730                failure(
9731                    NodeFailureCode::BackendBusy,
9732                    "native session preview capacity is busy",
9733                )
9734            })?;
9735        let preview = run_native_session_catalog_operation(
9736            catalog,
9737            "native session preview",
9738            move |catalog| {
9739                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9740                    &provider,
9741                    scope,
9742                    canonical_root.as_deref().map(Path::new),
9743                    &registered_workspace_roots,
9744                    catalog_revision,
9745                    recent_cutoff_unix_ms,
9746                    &selection_id,
9747                    message_limit,
9748                ))
9749            },
9750        )
9751        .await?;
9752        preview
9753            .map(Into::into)
9754            .map_err(native_session_preview_failure)
9755    }
9756
9757    async fn index_native_session(
9758        &self,
9759        selection: NativeSessionSelection,
9760        display_name: String,
9761    ) -> Result<ManagedSessionRecord, NodeFailure> {
9762        selection
9763            .validate()
9764            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9765        if selection.route.scope
9766            != gate4agent_types::NativeSessionCatalogScope::Workspace
9767        {
9768            return Err(failure(
9769                NodeFailureCode::WorkspaceRegistrationRequired,
9770                "register the external project as a workspace before indexing or resuming it",
9771            ));
9772        }
9773        let workspace_id = selection
9774            .route
9775            .workspace_id
9776            .clone()
9777            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9778        let canonical_root = self
9779            .native_session_route_root(&selection.route)?
9780            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9781        let registered_workspace_roots = self.workspace_roots();
9782        let provider = selection.route.provider.clone();
9783        let catalog_revision = selection.catalog_revision;
9784        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9785        let selection_id = selection.selection_id.clone();
9786        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9787            failure(
9788                NodeFailureCode::InvalidRequest,
9789                "native session index is not configured",
9790            )
9791        })?;
9792        let _permit = Arc::clone(&self.inspection_slots)
9793            .try_acquire_owned()
9794            .map_err(|_| {
9795                failure(
9796                    NodeFailureCode::BackendBusy,
9797                    "native session index capacity is busy",
9798                )
9799            })?;
9800        let resolved = run_native_session_catalog_operation(
9801            catalog,
9802            "native session index resolution",
9803            move |catalog| {
9804                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9805                    &provider,
9806                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9807                    Some(Path::new(&canonical_root)),
9808                    &registered_workspace_roots,
9809                    catalog_revision,
9810                    recent_cutoff_unix_ms,
9811                    &selection_id,
9812                ))
9813            },
9814        )
9815        .await?
9816        .map_err(native_session_preview_failure)?;
9817        self.index_provider_session_with_policy(
9818            workspace_id,
9819            selection.route.provider,
9820            resolved.identity,
9821            display_name,
9822            true,
9823        )
9824    }
9825
9826    async fn preview_native_session_by_session_id(
9827        &self,
9828        workspace_id: WorkspaceId,
9829        provider: AgentId,
9830        session_id: String,
9831        message_limit: u16,
9832    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9833        let canonical_root = self.workspace_root(&workspace_id)?;
9834        let registered_workspace_roots = self.workspace_roots();
9835        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9836            failure(
9837                NodeFailureCode::InvalidRequest,
9838                "native session preview is not configured",
9839            )
9840        })?;
9841        let _permit = Arc::clone(&self.inspection_slots)
9842            .try_acquire_owned()
9843            .map_err(|_| {
9844                failure(
9845                    NodeFailureCode::BackendBusy,
9846                    "native session preview capacity is busy",
9847                )
9848            })?;
9849        run_native_session_catalog_operation(
9850            catalog,
9851            "native session preview",
9852            move |catalog| {
9853                catalog.preview_session_id_for_workspace(
9854                    &provider,
9855                    Path::new(&canonical_root),
9856                    &registered_workspace_roots,
9857                    &session_id,
9858                    message_limit,
9859                )
9860            },
9861        )
9862        .await
9863    }
9864
9865    async fn register_workspace(
9866        &self,
9867        workspace_id: WorkspaceId,
9868        root: String,
9869    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9870        validate_workspace_request_root(&root)?;
9871        let canonicalize_workspace_id = workspace_id.clone();
9872        let workspace = tokio::task::spawn_blocking(move || {
9873            WorkspaceConfig::new(canonicalize_workspace_id, root)
9874        })
9875        .await
9876        .map_err(|error| {
9877            failure(
9878                NodeFailureCode::BackendOperationFailed,
9879                &format!("workspace canonicalization task failed: {error}"),
9880            )
9881        })?
9882        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9883        let transaction = self
9884            .state_transaction
9885            .lock()
9886            .unwrap_or_else(|poisoned| poisoned.into_inner());
9887        let mut workspaces = self
9888            .workspaces
9889            .write()
9890            .unwrap_or_else(|poisoned| poisoned.into_inner());
9891        if workspaces.contains_key(&workspace_id) {
9892            return Err(failure(
9893                NodeFailureCode::DuplicateWorkspaceId,
9894                "workspace ID is already registered",
9895            ));
9896        }
9897        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9898            platform::roots_equal(existing_root, workspace.canonical_root())
9899        }) {
9900            return Err(failure(
9901                NodeFailureCode::DuplicateWorkspaceRoot,
9902                &format!("workspace root is already registered as '{existing_id}'"),
9903            ));
9904        }
9905        {
9906            let records = self
9907                .session_records
9908                .lock()
9909                .unwrap_or_else(|poisoned| poisoned.into_inner());
9910            if let Some(record) = records.records.values().find(|record| {
9911                record.workspace_id == workspace_id
9912                    && !record
9913                        .canonical_root
9914                        .as_utf8()
9915                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9916            }) {
9917                return Err(failure(
9918                    NodeFailureCode::SessionRecordConflict,
9919                    &format!(
9920                        "workspace ID is retained by session record '{}' at another root",
9921                        record.record_id,
9922                    ),
9923                ));
9924            }
9925        }
9926        let snapshot = WorkspaceSnapshot {
9927            workspace_id: workspace_id.clone(),
9928            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9929            sessions: Vec::new(),
9930            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9931            managed_worktree_profiles: Some(WorktreeProfileInventory {
9932                profiles: Vec::new(),
9933            }),
9934        };
9935        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9936        drop(workspaces);
9937        let previous_records = {
9938            let mut records = self
9939                .session_records
9940                .lock()
9941                .unwrap_or_else(|poisoned| poisoned.into_inner());
9942            let previous = records.records.clone();
9943            for record in records.records.values_mut().filter(|record| {
9944                record.workspace_id == workspace_id
9945                    && record
9946                        .canonical_root
9947                        .as_utf8()
9948                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9949            }) {
9950                if record.active_session.is_none() {
9951                    record.state = detached_record_state(record);
9952                }
9953                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9954                    record.last_error = None;
9955                }
9956                record.updated_at_unix_ms = unix_time_ms();
9957            }
9958            previous
9959        };
9960        if let Err(error) = self.persist_state_locked() {
9961            self.workspaces
9962                .write()
9963                .unwrap_or_else(|poisoned| poisoned.into_inner())
9964                .remove(&workspace_id);
9965            self.session_records
9966                .lock()
9967                .unwrap_or_else(|poisoned| poisoned.into_inner())
9968                .records = previous_records;
9969            return Err(persistence_failure(error));
9970        }
9971        let restored = self
9972            .session_records
9973            .lock()
9974            .unwrap_or_else(|poisoned| poisoned.into_inner())
9975            .records
9976            .values()
9977            .filter(|record| record.workspace_id == workspace_id)
9978            .cloned()
9979            .collect::<Vec<_>>();
9980        drop(transaction);
9981        self.publish(NodeEvent::WorkspaceAdded {
9982            workspace: snapshot.clone(),
9983        });
9984        for record in restored {
9985            self.publish_record(record);
9986        }
9987        Ok(snapshot)
9988    }
9989
9990    async fn create_standalone_workspace(
9991        &self,
9992        workspace_id: WorkspaceId,
9993        root: String,
9994        initial_branch: Option<String>,
9995    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9996        validate_workspace_request_root(&root)?;
9997        validate_git_revision("initial branch", initial_branch.as_deref())?;
9998        let preflight_workspace_id = workspace_id.clone();
9999        let preflight_root = root.clone();
10000        let canonical_candidate = tokio::task::spawn_blocking(move || {
10001            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10002        })
10003        .await
10004        .map_err(|_| failure(
10005            NodeFailureCode::BackendOperationFailed,
10006            "standalone workspace root preflight task failed",
10007        ))?
10008        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10009        {
10010            let workspaces = self
10011                .workspaces
10012                .read()
10013                .unwrap_or_else(|poisoned| poisoned.into_inner());
10014            if workspaces.contains_key(&workspace_id) {
10015                return Err(failure(
10016                    NodeFailureCode::DuplicateWorkspaceId,
10017                    "workspace ID is already registered",
10018                ));
10019            }
10020            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10021                platform::roots_equal(existing_root, &canonical_candidate)
10022            }) {
10023                return Err(failure(
10024                    NodeFailureCode::DuplicateWorkspaceRoot,
10025                    &format!("workspace root is already registered as '{existing_id}'"),
10026                ));
10027            }
10028        }
10029        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10030            .await
10031            .map_err(standalone_workspace_failure)?;
10032        prepared
10033            .verify_for_registration()
10034            .await
10035            .map_err(standalone_workspace_failure)?;
10036        match self
10037            .register_workspace(workspace_id, prepared.root().to_owned())
10038            .await
10039        {
10040            Ok(workspace) => Ok(workspace),
10041            Err(registration_error) => {
10042                if prepared.compensate_registration_failure().await {
10043                    Err(registration_error)
10044                } else {
10045                    Err(failure(
10046                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10047                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10048                    ))
10049                }
10050            }
10051        }
10052    }
10053
10054    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10055        let bindings = self
10056            .session_bindings
10057            .lock()
10058            .unwrap_or_else(|poisoned| poisoned.into_inner());
10059        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10060            return Err(failure(
10061                NodeFailureCode::WorkspaceBusy,
10062                "workspace retains a bound session; remove every session before unregistering it",
10063            ));
10064        }
10065        drop(bindings);
10066        let transaction = self
10067            .state_transaction
10068            .lock()
10069            .unwrap_or_else(|poisoned| poisoned.into_inner());
10070        let mut workspaces = self
10071            .workspaces
10072            .write()
10073            .unwrap_or_else(|poisoned| poisoned.into_inner());
10074        if !workspaces.contains_key(workspace_id) {
10075            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10076        }
10077        if workspaces.len() == 1 {
10078            return Err(failure(
10079                NodeFailureCode::LastWorkspace,
10080                "node must retain at least one workspace",
10081            ));
10082        }
10083        let removed_root = workspaces
10084            .remove(workspace_id)
10085            .expect("workspace presence was validated");
10086        drop(workspaces);
10087        let previous_records = {
10088            let mut records = self
10089                .session_records
10090                .lock()
10091                .unwrap_or_else(|poisoned| poisoned.into_inner());
10092            let previous = records.records.clone();
10093            for record in records
10094                .records
10095                .values_mut()
10096                .filter(|record| &record.workspace_id == workspace_id)
10097            {
10098                record.active_session = None;
10099                record.state = ManagedSessionState::Unavailable;
10100                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10101                record.updated_at_unix_ms = unix_time_ms();
10102            }
10103            previous
10104        };
10105        if let Err(error) = self.persist_state_locked() {
10106            self.workspaces
10107                .write()
10108                .unwrap_or_else(|poisoned| poisoned.into_inner())
10109                .insert(workspace_id.clone(), removed_root);
10110            self.session_records
10111                .lock()
10112                .unwrap_or_else(|poisoned| poisoned.into_inner())
10113                .records = previous_records;
10114            return Err(persistence_failure(error));
10115        }
10116        let unavailable = self
10117            .session_records
10118            .lock()
10119            .unwrap_or_else(|poisoned| poisoned.into_inner())
10120            .records
10121            .values()
10122            .filter(|record| &record.workspace_id == workspace_id)
10123            .cloned()
10124            .collect::<Vec<_>>();
10125        drop(transaction);
10126        self.publish(NodeEvent::WorkspaceRemoved {
10127            workspace_id: workspace_id.clone(),
10128        });
10129        for record in unavailable {
10130            self.publish_record(record);
10131        }
10132        Ok(())
10133    }
10134
10135    async fn create_worktree(
10136        &self,
10137        source_workspace_id: WorkspaceId,
10138        workspace_id: WorkspaceId,
10139        target_root: String,
10140        branch: String,
10141        base: Option<String>,
10142    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10143        self.require_worktree_service(&source_workspace_id)?;
10144        validate_workspace_request_root(&target_root)?;
10145        validate_git_revision("worktree base", base.as_deref())?;
10146        let source_root = self.workspace_root(&source_workspace_id)?;
10147        {
10148            let workspaces = self
10149                .workspaces
10150                .read()
10151                .unwrap_or_else(|poisoned| poisoned.into_inner());
10152            if workspaces.contains_key(&workspace_id) {
10153                return Err(failure(
10154                    NodeFailureCode::DuplicateWorkspaceId,
10155                    "workspace ID is already registered",
10156                ));
10157            }
10158            if let Some((existing_id, _)) = workspaces
10159                .iter()
10160                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10161            {
10162                return Err(failure(
10163                    NodeFailureCode::DuplicateWorkspaceRoot,
10164                    &format!("workspace root is already registered as '{existing_id}'"),
10165                ));
10166            }
10167        }
10168        let mut worktree = create_git_worktree(
10169            &source_root,
10170            &target_root,
10171            &branch,
10172            base.as_deref(),
10173        )
10174        .await
10175        .map_err(git_worktree_failure)?;
10176        let workspace = match self
10177            .register_workspace(workspace_id, worktree.path.clone())
10178            .await
10179        {
10180            Ok(workspace) => workspace,
10181            Err(registration_error) => {
10182                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10183                    return Err(failure(
10184                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10185                        "worktree registration failed and compensation requires recovery",
10186                    ));
10187                }
10188                return Err(failure(
10189                    registration_error.code,
10190                    &registration_error.message,
10191                ));
10192            }
10193        };
10194        worktree.workspace_id = Some(workspace.workspace_id.clone());
10195        Ok((protocol_worktree(worktree), workspace))
10196    }
10197
10198    async fn remove_worktree(
10199        &self,
10200        source_workspace_id: WorkspaceId,
10201        target_root: String,
10202    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10203        self.require_worktree_service(&source_workspace_id)?;
10204        validate_workspace_request_root(&target_root)?;
10205        let source_root = self.workspace_root(&source_workspace_id)?;
10206        let target_root = normalize_worktree_removal_target(&target_root)
10207            .map_err(git_worktree_failure)?;
10208        let target = list_git_worktrees(&source_root)
10209            .await
10210            .map_err(git_worktree_failure)?
10211            .into_iter()
10212            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10213            .ok_or_else(|| {
10214                failure(
10215                    NodeFailureCode::WorktreeProtected,
10216                    "refusing to remove a path that is not in Git's worktree listing",
10217                )
10218            })?;
10219        let registered_workspace_id = {
10220            let workspaces = self
10221                .workspaces
10222                .read()
10223                .unwrap_or_else(|poisoned| poisoned.into_inner());
10224            let matched = workspaces
10225                .iter()
10226                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10227                .map(|(workspace_id, _)| workspace_id.clone());
10228            if matched.is_some() && workspaces.len() == 1 {
10229                return Err(failure(
10230                    NodeFailureCode::LastWorkspace,
10231                    "node must retain at least one workspace",
10232                ));
10233            }
10234            matched
10235        };
10236        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10237            let bindings = self
10238                .session_bindings
10239                .lock()
10240                .unwrap_or_else(|poisoned| poisoned.into_inner());
10241            if bindings
10242                .values()
10243                .any(|binding| &binding.workspace_id == workspace_id)
10244            {
10245                return Err(failure(
10246                    NodeFailureCode::WorkspaceBusy,
10247                    "worktree workspace retains a bound session; remove every session first",
10248                ));
10249            }
10250        }
10251        remove_git_worktree(&source_root, &target.path)
10252            .await
10253            .map_err(git_worktree_failure)?;
10254        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255            self.unregister_workspace(workspace_id)
10256                .map_err(|error| failure(error.code, &error.message))?;
10257        }
10258        Ok(registered_workspace_id)
10259    }
10260
10261    async fn cleanup_managed_worktree(
10262        &self,
10263        lease_id: &ManagedWorktreeLeaseId,
10264        explicit: bool,
10265    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10266        let lease = self.managed_worktrees.lock()
10267            .unwrap_or_else(|poisoned| poisoned.into_inner())
10268            .get(lease_id)
10269            .cloned()
10270            .ok_or_else(|| failure(
10271                NodeFailureCode::UnknownManagedWorktreeLease,
10272                "managed worktree lease does not exist",
10273            ))?;
10274        if lease.state == ManagedWorktreeLeaseState::Removed {
10275            return Ok(lease.snapshot());
10276        }
10277        if lease.has_holders() {
10278            return Err(failure(
10279                NodeFailureCode::ManagedWorktreeBusy,
10280                "managed worktree lease still has a session or durable-record holder",
10281            ));
10282        }
10283        if self
10284            .materializations
10285            .lock()
10286            .unwrap_or_else(|poisoned| poisoned.into_inner())
10287            .values()
10288            .any(|record| record.managed_lease_id() == Some(lease_id))
10289        {
10290            return Err(failure(
10291                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10292                "managed worktree cleanup is blocked by session environment ownership",
10293            ));
10294        }
10295        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10296            let snapshot = {
10297                let mut registry = self.managed_worktrees.lock()
10298                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10299                let current = registry.get_mut(lease_id)
10300                    .expect("managed worktree lease remains present");
10301                current.state = ManagedWorktreeLeaseState::Retained;
10302                current.cleanup_failure = None;
10303                current.updated_at_unix_ms = unix_time_ms();
10304                current.snapshot()
10305            };
10306            if self.persist_state().is_err() {
10307                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10308            }
10309            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10310            return Ok(snapshot);
10311        }
10312        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10313        if profile.revision() != &lease.profile_revision {
10314            self.mark_managed_recovery_required(
10315                lease_id,
10316                ManagedWorktreeCleanupFailure::OwnershipConflict,
10317            );
10318            return Err(failure(
10319                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10320                "managed worktree profile revision changed",
10321            ));
10322        }
10323        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10324            self.mark_managed_recovery_required(
10325                lease_id,
10326                ManagedWorktreeCleanupFailure::OwnershipConflict,
10327            );
10328            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10329        })?;
10330        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10331        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10332            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10333            managed_git_worktree_failure(error)
10334        })?;
10335        let target = listed.iter()
10336            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10337        if let Some(target) = target {
10338            if target.is_main || target.is_bare || !exact_owned_worktree(
10339                &lease,
10340                &target.path,
10341                target.branch.as_deref(),
10342                &target.head,
10343            ) {
10344                self.mark_managed_recovery_required(
10345                    lease_id,
10346                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10347                );
10348                return Err(failure(
10349                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10350                    "Git worktree identity no longer matches the managed lease",
10351                ));
10352            }
10353            if target.prunable {
10354                self.mark_managed_cleanup_blocked(
10355                    lease_id,
10356                    ManagedWorktreeCleanupFailure::Prunable,
10357                );
10358                return Err(failure(
10359                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10360                    "managed worktree is prunable and requires recovery",
10361                ));
10362            }
10363            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10364                let failure_kind = match error.kind {
10365                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10366                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10367                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10368                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10369                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10370                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10371                };
10372                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10373                    self.mark_managed_recovery_required(lease_id, failure_kind);
10374                } else {
10375                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10376                }
10377                return Err(managed_git_worktree_failure(error));
10378            }
10379        } else {
10380            match std::fs::symlink_metadata(&lease.target_root) {
10381                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10382                Ok(_) => {
10383                    self.mark_managed_recovery_required(
10384                        lease_id,
10385                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10386                    );
10387                    return Err(failure(
10388                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10389                        "managed target exists but is not owned by Git",
10390                    ));
10391                }
10392                Err(_) => {
10393                    self.mark_managed_cleanup_blocked(
10394                        lease_id,
10395                        ManagedWorktreeCleanupFailure::Backend,
10396                    );
10397                    return Err(failure(
10398                        NodeFailureCode::BackendOperationFailed,
10399                        "managed target identity could not be inspected",
10400                    ));
10401                }
10402            }
10403        }
10404        let registered = self.workspaces.read()
10405            .unwrap_or_else(|poisoned| poisoned.into_inner())
10406            .contains_key(&lease.workspace_id);
10407        if registered {
10408            self.unregister_workspace(&lease.workspace_id)?;
10409        }
10410        let removed = {
10411            self.managed_worktrees.lock()
10412                .unwrap_or_else(|poisoned| poisoned.into_inner())
10413                .tombstone(lease_id, unix_time_ms())
10414                .expect("managed worktree lease remains present during cleanup")
10415        };
10416        self.persist_state().map_err(persistence_failure)?;
10417        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10418        Ok(removed.snapshot())
10419    }
10420
10421    fn mark_managed_cleanup_blocked(
10422        &self,
10423        lease_id: &ManagedWorktreeLeaseId,
10424        failure_kind: ManagedWorktreeCleanupFailure,
10425    ) {
10426        let snapshot = {
10427            let mut registry = self.managed_worktrees.lock()
10428                .unwrap_or_else(|poisoned| poisoned.into_inner());
10429            let Some(lease) = registry.get_mut(lease_id) else { return };
10430            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10431            lease.cleanup_failure = Some(failure_kind);
10432            lease.updated_at_unix_ms = unix_time_ms();
10433            lease.snapshot()
10434        };
10435        let _ = self.persist_state();
10436        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10437    }
10438
10439    fn require_worktree_service(
10440        &self,
10441        source_workspace_id: &WorkspaceId,
10442    ) -> Result<(), NodeFailure> {
10443        if self.managed_worktrees.lock()
10444            .unwrap_or_else(|poisoned| poisoned.into_inner())
10445            .lease_for_workspace(source_workspace_id)
10446            .is_some()
10447        {
10448            return Err(failure(
10449                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10450                "managed worktree targets are internal-only workspaces",
10451            ));
10452        }
10453        match self
10454            .worktree_service_modes
10455            .get(source_workspace_id)
10456            .copied()
10457            .unwrap_or(WorktreeServiceMode::Manual)
10458        {
10459            WorktreeServiceMode::Manual => Ok(()),
10460            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10461                NodeFailureCode::UnsupportedSpawnCapability,
10462                "Git worktree service mode is unavailable in this node build",
10463            )),
10464        }
10465    }
10466
10467    fn managed_reservation_conflict(
10468        &self,
10469        workspace_id: Option<&WorkspaceId>,
10470        root: Option<&str>,
10471    ) -> bool {
10472        self.managed_worktrees.lock()
10473            .unwrap_or_else(|poisoned| poisoned.into_inner())
10474            .active_records()
10475            .any(|lease| {
10476                workspace_id.is_some_and(|workspace_id| {
10477                    workspace_id == &lease.source_workspace_id
10478                        || workspace_id == &lease.workspace_id
10479                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10480            })
10481    }
10482
10483    fn reject_managed_reservation(
10484        &self,
10485        workspace_id: Option<&WorkspaceId>,
10486        root: Option<&str>,
10487    ) -> Result<(), NodeFailure> {
10488        if self.managed_reservation_conflict(workspace_id, root) {
10489            Err(failure(
10490                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10491                "managed worktree ownership may only be mutated through its lease cleanup",
10492            ))
10493        } else {
10494            Ok(())
10495        }
10496    }
10497
10498    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10499        self.validate_address(address)?;
10500        self.workspace_root(&address.workspace_id)
10501    }
10502
10503    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10504    /// `require_policy`'s own doc on those two requirements), so they keep
10505    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10506    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10507    /// `false` on the `require_policy` call below for them because a PTY
10508    /// resume never needs those facts, not because this method cannot look
10509    /// them up.
10510    ///
10511    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10512    /// the SAME `binding.runtime_policy` every other requirement reads here
10513    /// (one computed policy, read by every gate). That policy is no longer
10514    /// PTY-terminal-verification-shaped for an ACP session --
10515    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10516    /// `provider_runtime::policy_for_transport` -- so consulting it for
10517    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10518    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10519    /// even though a prompt is an ACP session's only interface). Pipe keeps
10520    /// reading the same PTY-probe-derived value it always has --
10521    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10522    /// its own doc for why).
10523    ///
10524    /// What this method still adds on top, for ACP/Pipe only, is a
10525    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10526    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10527    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10528    /// read off the same live `SessionSnapshot` state `require_acp_session`
10529    /// already reads for the ACP control verbs -- `TurnStarted` observed
10530    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10531    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10532    /// its fate in vendor-specific behaviour this node cannot observe (one
10533    /// agent may queue it, another drop it, another interleave it), so this
10534    /// refuses it outright rather than gambling on that -- a rule with no
10535    /// PTY analogue, which is why PTY does not take this extra precondition.
10536    fn require_session_runtime_policy(
10537        &self,
10538        address: &SessionAddress,
10539        requirement: ProviderRuntimeRequirement,
10540    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10541        let runtime_policy = {
10542            let bindings = self
10543                .session_bindings
10544                .lock()
10545                .unwrap_or_else(|poisoned| poisoned.into_inner());
10546            let binding = bindings
10547                .get(&address.session.instance_id)
10548                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10549            if binding.workspace_id != address.workspace_id {
10550                return Err(failure(
10551                    NodeFailureCode::SessionWorkspaceMismatch,
10552                    "session belongs to another workspace",
10553                ));
10554            }
10555            if binding.generation != address.session.generation {
10556                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10557            }
10558            binding.runtime_policy
10559        };
10560        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10561            let snapshot = self.internal_session_snapshot(address)?;
10562            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10563                && snapshot.provider.lead_activity != ProviderActivity::Idle
10564            {
10565                return Err(failure(
10566                    NodeFailureCode::TurnInFlight,
10567                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10568                ));
10569            }
10570        }
10571        require_policy(runtime_policy, false, false, requirement)
10572            .map(|()| runtime_policy)
10573            .map_err(|_| failure(
10574                NodeFailureCode::UnsupportedCapability,
10575                "session runtime policy does not admit this semantic operation",
10576            ))
10577    }
10578
10579    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10580        let mut bindings = self
10581            .session_bindings
10582            .lock()
10583            .unwrap_or_else(|poisoned| poisoned.into_inner());
10584        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10585            binding.workspace_id == address.workspace_id
10586                && binding.generation == address.session.generation
10587        }) {
10588            let removed = bindings.remove(&address.session.instance_id);
10589            self.clear_terminal_frame_watermark(address);
10590            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10591            return removed;
10592        }
10593        None
10594    }
10595
10596    fn cleanup_session_owned_materialization(
10597        &self,
10598        address: &SessionAddress,
10599        binding: &SessionBinding,
10600    ) -> Result<(), NodeFailure> {
10601        let Some(id) = binding.materialization_id.as_ref() else {
10602            return Ok(());
10603        };
10604        let owner = self
10605            .materializations
10606            .lock()
10607            .unwrap_or_else(|poisoned| poisoned.into_inner())
10608            .get(id)
10609            .map(|record| record.owner().clone());
10610        match owner {
10611            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10612            Some(MaterializationOwner::Session {
10613                incarnation_id,
10614                instance_id,
10615                generation,
10616            }) if incarnation_id == self.incarnation_id
10617                && instance_id == address.session.instance_id
10618                && generation == address.session.generation =>
10619            {
10620                self.cleanup_materialization(id)
10621            }
10622            Some(MaterializationOwner::Session { .. }) => Err(failure(
10623                NodeFailureCode::BackendOperationFailed,
10624                "session environment ownership requires recovery",
10625            )),
10626        }
10627    }
10628
10629    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10630        let mut watermarks = self
10631            .terminal_frame_watermarks
10632            .lock()
10633            .unwrap_or_else(|poisoned| poisoned.into_inner());
10634        if watermarks
10635            .get(&address.session.instance_id)
10636            .is_some_and(|(current, _)| current == address)
10637        {
10638            watermarks.remove(&address.session.instance_id);
10639        }
10640    }
10641
10642    #[cfg(test)]
10643    fn address_for(
10644        &self,
10645        instance_id: AgentInstanceId,
10646        generation: SessionGeneration,
10647    ) -> Option<SessionAddress> {
10648        let binding = self.session_bindings
10649            .lock()
10650            .unwrap_or_else(|poisoned| poisoned.into_inner())
10651            .get(&instance_id)
10652            .cloned()?;
10653        (binding.generation == generation).then_some(SessionAddress {
10654            workspace_id: binding.workspace_id,
10655            session: SessionKey { instance_id, generation },
10656        })
10657    }
10658
10659    fn arm_resume(
10660        &self,
10661        address: &SessionAddress,
10662        command_id: CommandId,
10663        runtime_policy: ProviderRuntimePolicy,
10664    ) -> Result<(), NodeFailure> {
10665        let mut bindings = self
10666            .session_bindings
10667            .lock()
10668            .unwrap_or_else(|poisoned| poisoned.into_inner());
10669        let binding = bindings
10670            .get_mut(&address.session.instance_id)
10671            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10672        if binding.workspace_id != address.workspace_id {
10673            return Err(failure(
10674                NodeFailureCode::SessionWorkspaceMismatch,
10675                "session belongs to another workspace",
10676            ));
10677        }
10678        if binding.generation != address.session.generation {
10679            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10680        }
10681        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10682        Ok(())
10683    }
10684
10685    fn clear_armed_resume(&self, address: &SessionAddress) {
10686        let mut bindings = self
10687            .session_bindings
10688            .lock()
10689            .unwrap_or_else(|poisoned| poisoned.into_inner());
10690        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10691            if binding.workspace_id == address.workspace_id
10692                && binding.generation == address.session.generation
10693            {
10694                binding.pending_resume = None;
10695            }
10696        }
10697    }
10698
10699    fn publish_control(&self, event: ControlEvent) {
10700        let revokes_harness_mcp = matches!(
10701            &event.event,
10702            ControlEventKind::Exited { .. }
10703                | ControlEventKind::Failed { .. }
10704                | ControlEventKind::Removed
10705        );
10706        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10707            let mut bindings = self
10708                .session_bindings
10709                .lock()
10710                .unwrap_or_else(|poisoned| poisoned.into_inner());
10711            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10712                return;
10713            };
10714            let previous_address = SessionAddress {
10715                workspace_id: binding.workspace_id.clone(),
10716                session: SessionKey {
10717                    instance_id: event.instance_id,
10718                    generation: binding.generation,
10719                },
10720            };
10721            let mut resume_command_rejected = false;
10722            if binding.generation == event.generation {
10723                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10724                    let rejected_resume = matches!(
10725                        &event.event,
10726                        ControlEventKind::CommandRejected { .. }
10727                    ) && event.command_id == Some(pending_command_id);
10728                    let resume_failed = matches!(
10729                        &event.event,
10730                        ControlEventKind::ResumeDenied { .. }
10731                            | ControlEventKind::ResumeFailed { .. }
10732                    );
10733                    if pending_generation == binding.generation
10734                        && (rejected_resume || resume_failed)
10735                    {
10736                        resume_command_rejected = rejected_resume;
10737                        binding.pending_resume = None;
10738                    }
10739                }
10740            } else {
10741                let expected = binding.generation.0.checked_add(1);
10742                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10743                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10744                    generation == binding.generation
10745                })
10746                    || expected != Some(event.generation.0)
10747                    || !authorized
10748                {
10749                    return;
10750                }
10751                let (_, _, runtime_policy) = binding
10752                    .pending_resume
10753                    .expect("authorized resume retains its pending runtime policy");
10754                binding.generation = event.generation;
10755                binding.runtime_policy = runtime_policy;
10756                binding.pending_resume = None;
10757            }
10758            (
10759                SessionAddress {
10760                    workspace_id: binding.workspace_id.clone(),
10761                    session: SessionKey {
10762                        instance_id: event.instance_id,
10763                        generation: event.generation,
10764                    },
10765                },
10766                (previous_address.session.generation != event.generation)
10767                    .then_some(previous_address),
10768                binding.record_id.clone(),
10769                binding.runtime_policy.provider_session_identity,
10770                resume_command_rejected,
10771            )
10772        };
10773        if let Some(previous_address) = previous_address.as_ref() {
10774            self.clear_terminal_frame_watermark(previous_address);
10775        }
10776        if let Some(record_id) = record_id {
10777            self.reconcile_managed_record(
10778                &record_id,
10779                &address,
10780                &event,
10781                identity_admitted,
10782                resume_command_rejected,
10783            );
10784        }
10785        // Every state change a session's process lifecycle can reach gets a
10786        // line here: this is the single chokepoint every `ControlEventKind`
10787        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10788        // came from a fresh spawn or a resume. Per-message provider chatter
10789        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10790        // matched here -- that is exactly the per-frame noise this crate
10791        // family's level discipline forbids at INFO.
10792        match &event.event {
10793            ControlEventKind::Running { process_id } => {
10794                #[cfg(unix)]
10795                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10796                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10797                }
10798                tracing::info!(
10799                    node_id = %self.node_id,
10800                    workspace_id = %address.workspace_id,
10801                    session = ?address.session,
10802                    pid = ?process_id,
10803                    "session process is running",
10804                );
10805            }
10806            ControlEventKind::Exited { exit_code, forced } => {
10807                #[cfg(unix)]
10808                if let Some(proxy) = self.signer_proxy.get().cloned() {
10809                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10810                    if proxy.has_session(&key) {
10811                        // Revoke at core off the event path (blocking uplink call).
10812                        std::thread::spawn(move || proxy.release(&key));
10813                    }
10814                }
10815                tracing::info!(
10816                    node_id = %self.node_id,
10817                    workspace_id = %address.workspace_id,
10818                    session = ?address.session,
10819                    exit_code = ?exit_code,
10820                    forced = forced,
10821                    "session process exited",
10822                );
10823            }
10824            ControlEventKind::Failed { message } => {
10825                tracing::warn!(
10826                    node_id = %self.node_id,
10827                    workspace_id = %address.workspace_id,
10828                    session = ?address.session,
10829                    cause = %message,
10830                    "session failed",
10831                );
10832            }
10833            ControlEventKind::CommandRejected { message } => {
10834                tracing::warn!(
10835                    node_id = %self.node_id,
10836                    workspace_id = %address.workspace_id,
10837                    session = ?address.session,
10838                    cause = %message,
10839                    "session command rejected",
10840                );
10841            }
10842            ControlEventKind::Removed => {
10843                tracing::info!(
10844                    node_id = %self.node_id,
10845                    workspace_id = %address.workspace_id,
10846                    session = ?address.session,
10847                    "session removed",
10848                );
10849            }
10850            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10851                tracing::info!(
10852                    node_id = %self.node_id,
10853                    workspace_id = %address.workspace_id,
10854                    session = ?address.session,
10855                    provider_session = ?provider_session,
10856                    "session resume authorized",
10857                );
10858            }
10859            ControlEventKind::Resumed { session: provider_session, process_id } => {
10860                tracing::info!(
10861                    node_id = %self.node_id,
10862                    workspace_id = %address.workspace_id,
10863                    session = ?address.session,
10864                    provider_session = ?provider_session,
10865                    pid = ?process_id,
10866                    "session resumed",
10867                );
10868            }
10869            ControlEventKind::ResumeDenied { reason } => {
10870                tracing::warn!(
10871                    node_id = %self.node_id,
10872                    workspace_id = %address.workspace_id,
10873                    session = ?address.session,
10874                    cause = %reason,
10875                    "session resume denied",
10876                );
10877            }
10878            ControlEventKind::ResumeFailed { message } => {
10879                tracing::warn!(
10880                    node_id = %self.node_id,
10881                    workspace_id = %address.workspace_id,
10882                    session = ?address.session,
10883                    cause = %message,
10884                    "session resume failed",
10885                );
10886            }
10887            _ => {}
10888        }
10889        self.apply_pending_acp_options(&address, &event);
10890        self.apply_acp_auto_permit(&address, &event);
10891        let stream_chunk = agent_stream_chunk(&event);
10892        self.publish(NodeEvent::Control {
10893            address: address.clone(),
10894            event,
10895        });
10896        if let Some(chunk) = stream_chunk {
10897            self.publish_agent_stream_chunk(address.clone(), chunk);
10898        }
10899        if revokes_harness_mcp {
10900            SessionLifecycleHook::on_session_ended(self, &address);
10901        }
10902    }
10903
10904    fn reconcile_managed_record(
10905        &self,
10906        record_id: &SessionRecordId,
10907        address: &SessionAddress,
10908        event: &ControlEvent,
10909        identity_admitted: bool,
10910        resume_command_rejected: bool,
10911    ) {
10912        let _transaction = self
10913            .state_transaction
10914            .lock()
10915            .unwrap_or_else(|poisoned| poisoned.into_inner());
10916        let observed_identity = match &event.event {
10917            ControlEventKind::ProviderEvent {
10918                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10919                ..
10920            } if identity_admitted => Some(identity.clone()),
10921            _ => None,
10922        };
10923        let identity_was_observed = observed_identity.is_some();
10924        let replacement_id = observed_identity
10925            .as_ref()
10926            .and_then(|_| self.allocate_record_id().ok());
10927        let mut upserts = Vec::new();
10928        let mut removals = Vec::new();
10929        let mut rebind = None;
10930        let original_records;
10931        let original_materializations = self
10932            .materializations
10933            .lock()
10934            .unwrap_or_else(|poisoned| poisoned.into_inner())
10935            .clone();
10936        {
10937            let mut records = self
10938                .session_records
10939                .lock()
10940                .unwrap_or_else(|poisoned| poisoned.into_inner());
10941            original_records = records.records.clone();
10942            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10943                return;
10944            };
10945            if let Some(identity) = observed_identity {
10946                let existing_id = records
10947                    .records
10948                    .values()
10949                    .find(|record| {
10950                        record.record_id != *record_id
10951                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10952                                session_registry::same_provider_session(
10953                                    &record.provider,
10954                                    record_identity,
10955                                    &current_snapshot.provider,
10956                                    &identity,
10957                                )
10958                            })
10959                    })
10960                    .map(|record| record.record_id.clone());
10961                if let Some(existing_id) = existing_id {
10962                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10963                        record.mode == current_snapshot.mode
10964                            && record.workspace_id == current_snapshot.workspace_id
10965                            && record.environment_profile == current_snapshot.environment_profile
10966                            && record.bundle == current_snapshot.bundle
10967                            && record.context_id == current_snapshot.context_id
10968                            && record.context == current_snapshot.context
10969                            && record
10970                                .canonical_root
10971                                .as_utf8()
10972                                .zip(current_snapshot.canonical_root.as_utf8())
10973                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10974                    });
10975                    if !scope_matches {
10976                        if let Some(current) = records.records.get_mut(record_id) {
10977                            current.state = ManagedSessionState::Unavailable;
10978                            current.last_error =
10979                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10980                            current.updated_at_unix_ms = unix_time_ms();
10981                            upserts.push(current.clone());
10982                        }
10983                    } else {
10984                        let existing_is_active = records
10985                            .records
10986                            .get(&existing_id)
10987                            .is_some_and(|record| record.active_session.is_some());
10988                        if existing_is_active {
10989                            if let Some(current) = records.records.get_mut(record_id) {
10990                                current.state = ManagedSessionState::Unavailable;
10991                                current.last_error =
10992                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10993                                current.updated_at_unix_ms = unix_time_ms();
10994                                upserts.push(current.clone());
10995                            }
10996                        } else {
10997                            if current_snapshot.provider_session.is_some() {
10998                                if let Some(current) = records.records.get_mut(record_id) {
10999                                    current.active_session = None;
11000                                    current.state = ManagedSessionState::Dormant;
11001                                    current.updated_at_unix_ms = unix_time_ms();
11002                                    upserts.push(current.clone());
11003                                }
11004                            } else {
11005                                records.records.remove(record_id);
11006                                removals.push(record_id.clone());
11007                            }
11008                            if let Some(existing) = records.records.get_mut(&existing_id) {
11009                                existing.provider_session = Some(identity.clone());
11010                                existing.active_session = Some(address.clone());
11011                                existing.state = ManagedSessionState::Live;
11012                                existing.updated_at_unix_ms = unix_time_ms();
11013                                existing.last_error = None;
11014                                upserts.push(existing.clone());
11015                            }
11016                            rebind = Some(existing_id);
11017                        }
11018                    }
11019                } else if current_snapshot
11020                    .provider_session
11021                    .as_ref()
11022                    .is_some_and(|current| {
11023                        !session_registry::same_provider_session(
11024                            &current_snapshot.provider,
11025                            current,
11026                            &current_snapshot.provider,
11027                            &identity,
11028                        )
11029                    })
11030                {
11031                    if let Some(current) = records.records.get_mut(record_id) {
11032                        current.active_session = None;
11033                        if current.bundle.is_some() || current.context.is_some() {
11034                            let had_bundle = current.bundle.is_some();
11035                            current.provider_session = None;
11036                            current.bundle = None;
11037                            current.context_id = None;
11038                            current.context = None;
11039                            current.state = ManagedSessionState::Unavailable;
11040                            current.last_error = Some(if had_bundle {
11041                                "bundle-unavailable".to_owned()
11042                            } else {
11043                                "context-unavailable".to_owned()
11044                            });
11045                        } else {
11046                            current.state = ManagedSessionState::Dormant;
11047                        }
11048                        current.updated_at_unix_ms = unix_time_ms();
11049                        upserts.push(current.clone());
11050                    }
11051                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11052                        if let Some(current) = records.records.get_mut(record_id) {
11053                            current.state = ManagedSessionState::Unavailable;
11054                            current.last_error =
11055                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11056                            current.updated_at_unix_ms = unix_time_ms();
11057                            upserts.push(current.clone());
11058                        }
11059                    } else if let Some(new_record_id) = replacement_id {
11060                        let now = unix_time_ms();
11061                        let replacement = ManagedSessionRecord {
11062                            record_id: new_record_id.clone(),
11063                            display_name: format!(
11064                                "{} #{}",
11065                                current_snapshot.provider.as_str(),
11066                                records
11067                                    .records
11068                                    .values()
11069                                    .filter(|record| {
11070                                        record.provider == current_snapshot.provider
11071                                    })
11072                                    .count()
11073                                    .saturating_add(1),
11074                            ),
11075                            provider: current_snapshot.provider.clone(),
11076                            mode: current_snapshot.mode,
11077                            state: ManagedSessionState::Live,
11078                            workspace_id: current_snapshot.workspace_id,
11079                            canonical_root: current_snapshot.canonical_root,
11080                            provider_session: Some(identity),
11081                            active_session: Some(address.clone()),
11082                            environment_profile: current_snapshot.environment_profile,
11083                            bundle: current_snapshot.bundle,
11084                            context_id: current_snapshot.context_id,
11085                            context: current_snapshot.context,
11086                            // This is a fresh record_id representing the session under its
11087                            // newly-observed identity; it has not yet produced any clean
11088                            // exit of its own, so it starts with no exported pack (the old
11089                            // record, still addressable under its own record_id, keeps
11090                            // whatever exported_context it already had).
11091                            exported_context: None,
11092                            task_binding: current_snapshot.task_binding,
11093                            created_at_unix_ms: now,
11094                            updated_at_unix_ms: now,
11095                            last_error: None,
11096                        };
11097                        records
11098                            .records
11099                            .insert(new_record_id.clone(), replacement.clone());
11100                        upserts.push(replacement);
11101                        rebind = Some(new_record_id);
11102                    } else if let Some(current) = records.records.get_mut(record_id) {
11103                        current.state = ManagedSessionState::Unavailable;
11104                        current.last_error =
11105                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11106                        upserts.push(current.clone());
11107                    }
11108                } else if let Some(current) = records.records.get_mut(record_id) {
11109                    current.provider_session = Some(identity);
11110                    current.active_session = Some(address.clone());
11111                    current.state = ManagedSessionState::Live;
11112                    current.updated_at_unix_ms = unix_time_ms();
11113                    current.last_error = None;
11114                    upserts.push(current.clone());
11115                }
11116            } else if let Some(current) = records.records.get_mut(record_id) {
11117                if resume_command_rejected {
11118                    current.active_session = None;
11119                    current.state = detached_record_state(current);
11120                    current.updated_at_unix_ms = unix_time_ms();
11121                    current.last_error =
11122                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11123                    upserts.push(current.clone());
11124                } else {
11125                    match &event.event {
11126                    ControlEventKind::Running { .. }
11127                    | ControlEventKind::ResumeAuthorized { .. }
11128                    | ControlEventKind::Resumed { .. } => {
11129                        current.active_session = Some(address.clone());
11130                        current.state = active_record_state(
11131                            identity_admitted,
11132                            current.provider_session.is_some(),
11133                        );
11134                        current.updated_at_unix_ms = unix_time_ms();
11135                        current.last_error = None;
11136                        upserts.push(current.clone());
11137                    }
11138                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11139                        current.active_session = None;
11140                        current.state = detached_record_state(current);
11141                        current.updated_at_unix_ms = unix_time_ms();
11142                        upserts.push(current.clone());
11143                    }
11144                    ControlEventKind::Failed { message }
11145                    | ControlEventKind::ResumeFailed { message } => {
11146                        current.active_session = None;
11147                        current.state = detached_record_state(current);
11148                        current.updated_at_unix_ms = unix_time_ms();
11149                        current.last_error = Some(
11150                            session_registry::sanitized_record_error_summary(message),
11151                        );
11152                        upserts.push(current.clone());
11153                    }
11154                    ControlEventKind::ResumeDenied { reason } => {
11155                        current.active_session = None;
11156                        current.state = detached_record_state(current);
11157                        current.updated_at_unix_ms = unix_time_ms();
11158                        current.last_error = Some(
11159                            session_registry::sanitized_record_error_summary(reason),
11160                        );
11161                        upserts.push(current.clone());
11162                    }
11163                        _ => {}
11164                    }
11165                }
11166            }
11167        }
11168        let materialization_owner_record = identity_was_observed.then(|| {
11169            rebind.clone().unwrap_or_else(|| record_id.clone())
11170        }).and_then(|candidate| {
11171            self.session_records
11172                .lock()
11173                .unwrap_or_else(|poisoned| poisoned.into_inner())
11174                .records
11175                .get(&candidate)
11176                .filter(|record| {
11177                    record.provider_session.is_some()
11178                        && record.active_session.as_ref() == Some(address)
11179                })
11180                .map(|_| candidate)
11181        });
11182        if let Some(owner_record_id) = materialization_owner_record {
11183            let materialization_id = self
11184                .session_bindings
11185                .lock()
11186                .unwrap_or_else(|poisoned| poisoned.into_inner())
11187                .get(&address.session.instance_id)
11188                .and_then(|binding| binding.materialization_id.clone());
11189            if let Some(materialization_id) = materialization_id {
11190                let mut transfer_failed = false;
11191                {
11192                    let mut materializations = self
11193                        .materializations
11194                        .lock()
11195                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11196                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11197                        match ownership.owner() {
11198                            MaterializationOwner::Session {
11199                                incarnation_id,
11200                                instance_id,
11201                                generation,
11202                            } if *incarnation_id == self.incarnation_id
11203                                && *instance_id == address.session.instance_id
11204                                && *generation == address.session.generation =>
11205                            {
11206                                transfer_failed = ownership
11207                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11208                                    .is_err();
11209                            }
11210                            MaterializationOwner::Record { record_id }
11211                                if record_id == &owner_record_id => {}
11212                            MaterializationOwner::Record { record_id } => {
11213                                let expected = record_id.clone();
11214                                transfer_failed = ownership
11215                                    .transfer_record_owner(
11216                                        &expected,
11217                                        owner_record_id.clone(),
11218                                        unix_time_ms(),
11219                                    )
11220                                    .is_err();
11221                            }
11222                            _ => transfer_failed = true,
11223                        }
11224                        if transfer_failed {
11225                            let _ = ownership.mark_recovery_required(unix_time_ms());
11226                        }
11227                    } else {
11228                        transfer_failed = true;
11229                    }
11230                }
11231                if transfer_failed {
11232                    if let Some(record) = self
11233                        .session_records
11234                        .lock()
11235                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11236                        .records
11237                        .get_mut(&owner_record_id)
11238                    {
11239                        record.state = ManagedSessionState::Unavailable;
11240                        record.last_error = Some("environment-profile-unavailable".to_owned());
11241                        record.updated_at_unix_ms = unix_time_ms();
11242                        upserts.push(record.clone());
11243                    }
11244                }
11245            }
11246        }
11247        if let Some(new_record_id) = rebind.clone() {
11248            if let Some(binding) = self
11249                .session_bindings
11250                .lock()
11251                .unwrap_or_else(|poisoned| poisoned.into_inner())
11252                .get_mut(&address.session.instance_id)
11253            {
11254                binding.record_id = Some(new_record_id);
11255            }
11256        }
11257        if upserts.is_empty() && removals.is_empty() {
11258            return;
11259        }
11260        if self.persist_state_locked().is_err() {
11261            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11262            let mut records = self
11263                .session_records
11264                .lock()
11265                .unwrap_or_else(|poisoned| poisoned.into_inner());
11266            records.records = original_records;
11267            *self
11268                .materializations
11269                .lock()
11270                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11271            if let Some(current) = records.records.get_mut(record_id) {
11272                current.state = ManagedSessionState::Unavailable;
11273                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11274                current.updated_at_unix_ms = unix_time_ms();
11275                upserts = vec![current.clone()];
11276            } else {
11277                upserts.clear();
11278            }
11279            removals.clear();
11280            drop(records);
11281            if let Some(binding) = self
11282                .session_bindings
11283                .lock()
11284                .unwrap_or_else(|poisoned| poisoned.into_inner())
11285                .get_mut(&address.session.instance_id)
11286            {
11287                binding.record_id = Some(record_id.clone());
11288            }
11289            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11290        }
11291        for record in upserts {
11292            self.publish_record(record);
11293        }
11294        for record_id in removals {
11295            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11296        }
11297    }
11298
11299    fn snapshot(&self) -> NodeSnapshot {
11300        let control = self.handle.snapshot();
11301        let bindings = self
11302            .session_bindings
11303            .lock()
11304            .unwrap_or_else(|poisoned| poisoned.into_inner())
11305            .clone();
11306        let workspace_roots = self
11307            .workspaces
11308            .read()
11309            .unwrap_or_else(|poisoned| poisoned.into_inner())
11310            .clone();
11311        let environment_profiles = self
11312            .environment_profiles
11313            .read()
11314            .unwrap_or_else(|poisoned| poisoned.into_inner());
11315        let mut workspaces = workspace_roots
11316            .iter()
11317            .map(|(workspace_id, canonical_root)| {
11318                (
11319                    workspace_id.clone(),
11320                    WorkspaceSnapshot {
11321                        workspace_id: workspace_id.clone(),
11322                        canonical_root: opaque_windows_path(canonical_root.clone()),
11323                        sessions: Vec::new(),
11324                        worktree_service_mode: Some(
11325                            self.worktree_service_modes
11326                                .get(workspace_id)
11327                                .copied()
11328                                .unwrap_or(WorktreeServiceMode::Manual),
11329                        ),
11330                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11331                            profiles: self
11332                                .managed_worktree_profiles
11333                                .get(workspace_id)
11334                                .into_iter()
11335                                .flat_map(BTreeMap::values)
11336                                .map(|profile| ManagedWorktreeProfileSummary {
11337                                    id: profile.profile_id().clone(),
11338                                    revision: profile.revision().clone(),
11339                                    retention: profile.retention(),
11340                                })
11341                                .collect(),
11342                        }),
11343                    },
11344                )
11345            })
11346            .collect::<BTreeMap<_, _>>();
11347        let mut agent_progress = Vec::new();
11348        for session in &control.sessions {
11349            let Some(binding) = bindings.get(&session.instance_id) else {
11350                continue;
11351            };
11352            if binding.generation != session.generation {
11353                continue;
11354            }
11355            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11356                let address = SessionAddress {
11357                    workspace_id: binding.workspace_id.clone(),
11358                    session: SessionKey {
11359                        instance_id: session.instance_id,
11360                        generation: session.generation,
11361                    },
11362                };
11363                if let Some(progress) =
11364                    agent_progress_from_provider_snapshot(address, &session.provider)
11365                {
11366                    agent_progress.push(progress);
11367                }
11368                workspace.sessions.push(session.clone());
11369            }
11370        }
11371        NodeSnapshot {
11372            node_id: self.node_id.clone(),
11373            enabled_providers: self.enabled_providers.clone(),
11374            provider_runtime_statuses: {
11375                let updates = self
11376                    .provider_runtime_status_updates
11377                    .read()
11378                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11379                ProviderRuntimeStatuses::new(
11380                    self.provider_runtime_statuses
11381                        .iter()
11382                        .filter(|status| !updates.contains_key(status.provider()))
11383                        .cloned()
11384                        .chain(updates.values().cloned()),
11385                )
11386                .expect("refreshed provider runtime inventory remains bounded and unique")
11387            },
11388            workspaces: workspaces.into_values().collect(),
11389            session_records: self
11390                .session_records
11391                .lock()
11392                .unwrap_or_else(|poisoned| poisoned.into_inner())
11393                .records
11394                .values()
11395                .cloned()
11396                .collect(),
11397            managed_worktrees: self.managed_worktrees
11398                .lock()
11399                .unwrap_or_else(|poisoned| poisoned.into_inner())
11400                .snapshots(),
11401            launch_inventory: Some(LaunchInventory {
11402                spawn_profiles: Some(
11403                    self.spawn_profiles
11404                        .iter()
11405                        .filter_map(|profile| {
11406                            resolved_spawn_profile_summary(profile, &environment_profiles)
11407                        })
11408                        .collect(),
11409                ),
11410                bundles: Some(
11411                    self.bundle_catalog
11412                        .read()
11413                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11414                        .iter()
11415                        .map(NodeBundle::receipt)
11416                        .collect(),
11417                ),
11418                // Opaque station allowlist ids from empty-default catalog /
11419                // optional startup loader. Never secrets. Dig2 probe stays stub.
11420                network_allowlists: {
11421                    let catalog = self
11422                        .network_allowlist_catalog
11423                        .read()
11424                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11425                    if catalog.is_empty() {
11426                        None
11427                    } else {
11428                        Some(catalog.ids().cloned().collect())
11429                    }
11430                },
11431            }),
11432            agent_progress,
11433        }
11434    }
11435
11436    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11437        assert!(
11438            !matches!(
11439                &event,
11440                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11441            ),
11442            "terminal frame and agent stream events must use their own dedicated live channel",
11443        );
11444        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11445        let sequence = history
11446            .last_sequence
11447            .checked_add(1)
11448            .expect("node event sequence exhausted");
11449        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11450            if let Some(evicted) = history.events.pop_front() {
11451                history.replay_floor_sequence = evicted
11452                    .sequence
11453                    .checked_add(1)
11454                    .expect("node replay floor sequence exhausted");
11455                history.removed_record_providers.remove(&evicted.sequence);
11456            }
11457        }
11458        match &event {
11459            NodeEvent::HarnessMcpReadCall { .. } => {
11460                panic!("transient harness MCP calls must not enter durable event history")
11461            }
11462            NodeEvent::SessionRecordUpserted { record } => {
11463                history
11464                    .record_providers
11465                    .insert(record.record_id.clone(), record.provider.clone());
11466            }
11467            NodeEvent::SessionRecordRemoved { record_id } => {
11468                if let Some(provider) = history.record_providers.remove(record_id) {
11469                    history.removed_record_providers.insert(sequence, provider);
11470                }
11471            }
11472            NodeEvent::ManagedWorktreeUpserted { .. }
11473            | NodeEvent::ManagedWorktreeRemoved { .. }
11474            | NodeEvent::Control { .. }
11475            | NodeEvent::ControllerChanged { .. }
11476            | NodeEvent::SessionRecordHistorySummarized { .. }
11477            | NodeEvent::WorkspaceAdded { .. }
11478            | NodeEvent::WorkspaceRemoved { .. }
11479            | NodeEvent::ResyncRequired { .. }
11480            | NodeEvent::AgentStream { .. }
11481            | NodeEvent::TerminalFrame { .. } => {}
11482        }
11483        let envelope = NodeEventEnvelope { sequence, event };
11484        history.events.push_back(envelope.clone());
11485        history.last_sequence = sequence;
11486        let _ = self.event_tx.send(envelope.clone());
11487        drop(history);
11488        envelope
11489    }
11490
11491    /// Publishes a transient (never durable) `NodeEvent` -- today only
11492    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11493    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11494    /// why this must never ride the shared `event_tx` bus). Returns whether
11495    /// the send reached at least one subscriber: `broadcast::Sender::send`
11496    /// fails in exactly one way, no active receiver at that instant, and
11497    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11498    /// return to fail the pending call immediately rather than leave it to
11499    /// expire silently at `LOCAL_READ_TIMEOUT`/
11500    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11501    fn publish_transient(&self, event: NodeEvent) -> bool {
11502        assert!(event.requires_harness_mcp_proxy_capability());
11503        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11504        let envelope = NodeEventEnvelope { sequence: 0, event };
11505        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11506            Ok(_) => true,
11507            Err(broadcast::error::SendError(undelivered)) => {
11508                if let Some(NodeEventEnvelope {
11509                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11510                    ..
11511                }) = undelivered.first() {
11512                    // `request.content_type` only, never a per-tool label --
11513                    // this crate does not decode `request.body` and does not
11514                    // hold `hatchery-harness-api` (Nested Control Plane
11515                    // doctrine, Law 3; see this crate's dependency on
11516                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11517                    tracing::warn!(
11518                        call_id = call_id.as_str(),
11519                        content_type = ?request.content_type,
11520                        cause = "no-subscribers",
11521                        "harness MCP proxy event could not be published",
11522                    );
11523                }
11524                false
11525            }
11526        }
11527    }
11528
11529    fn publish_terminal_frames(&self) {
11530        let snapshot = self.handle.snapshot();
11531        let bindings = self
11532            .session_bindings
11533            .lock()
11534            .unwrap_or_else(|poisoned| poisoned.into_inner());
11535        let candidates = snapshot
11536            .sessions
11537            .iter()
11538            .filter_map(|session| {
11539                let binding = bindings.get(&session.instance_id)?;
11540                if binding.generation != session.generation {
11541                    return None;
11542                }
11543                let frame = session.terminal_frame.clone()?;
11544                crate::session_signals::feed(
11545                    binding.workspace_id.as_str(),
11546                    session.instance_id.0,
11547                    session.agent_id.as_str(),
11548                    &frame.contents,
11549                );
11550                Some((
11551                    SessionAddress {
11552                        workspace_id: binding.workspace_id.clone(),
11553                        session: SessionKey {
11554                            instance_id: session.instance_id,
11555                            generation: session.generation,
11556                        },
11557                    },
11558                    frame,
11559                ))
11560            })
11561            .collect();
11562        drop(bindings);
11563        self.publish_terminal_frame_candidates(candidates);
11564    }
11565
11566    fn publish_terminal_frame_candidates(
11567        &self,
11568        candidates: Vec<(SessionAddress, TerminalFrame)>,
11569    ) {
11570        let mut watermarks = self
11571            .terminal_frame_watermarks
11572            .lock()
11573            .unwrap_or_else(|poisoned| poisoned.into_inner());
11574        let mut events = Vec::with_capacity(candidates.len());
11575        for (address, frame) in candidates {
11576            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11577            let advanced = watermarks
11578                .get(&address.session.instance_id)
11579                .map_or(true, |(current_address, sequence)| {
11580                    current_address != &address || frame.sequence > *sequence
11581                });
11582            if !advanced {
11583                continue;
11584            }
11585            if is_first_frame {
11586                // Do NOT sessionize on first PTY output: a session is a
11587                // provider conversation id, not bytes on the wire. Drafts
11588                // leave the provisional map only when HQ attaches
11589                // (Resize / TerminalBytes). The attach-never-arrived timer
11590                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11591                // disconnect grace starts only on a real client WS close.
11592                // This is the first PTY output this node has observed for
11593                // this session -- a one-time event per spawn, not per-frame
11594                // chatter (the watermark makes sure of that). A session that
11595                // reaches this line with no controller attached anywhere on
11596                // the node is running unobserved: exactly how processes from
11597                // repeated spawn attempts went orphaned for hours without a
11598                // single line about it.
11599                if self.controller_state().is_none() {
11600                    tracing::warn!(
11601                        node_id = %self.node_id,
11602                        workspace_id = %address.workspace_id,
11603                        session = ?address.session,
11604                        terminal_size = ?frame.size,
11605                        "session producing output with no controller attached",
11606                    );
11607                } else {
11608                    tracing::info!(
11609                        node_id = %self.node_id,
11610                        workspace_id = %address.workspace_id,
11611                        session = ?address.session,
11612                        terminal_size = ?frame.size,
11613                        "session produced its first output",
11614                    );
11615                }
11616            }
11617            watermarks.insert(
11618                address.session.instance_id,
11619                (address.clone(), frame.sequence),
11620            );
11621            if let Some((at, reported)) = self
11622                .pty_input_marks
11623                .lock()
11624                .unwrap_or_else(|poisoned| poisoned.into_inner())
11625                .get_mut(&address.session.instance_id)
11626            {
11627                // First frame after an input: PTY write -> output published.
11628                if !*reported && at.elapsed() < Duration::from_secs(5) {
11629                    *reported = true;
11630                    tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11631                }
11632            }
11633            events.push(NodeEvent::TerminalFrame { address, frame });
11634        }
11635        drop(watermarks);
11636        if events.is_empty() {
11637            return;
11638        }
11639        let mut history = self
11640            .history
11641            .lock()
11642            .unwrap_or_else(|poisoned| poisoned.into_inner());
11643        let mut envelopes = Vec::with_capacity(events.len());
11644        for event in events {
11645            let sequence = history
11646                .last_sequence
11647                .checked_add(1)
11648                .expect("node event sequence exhausted");
11649            history.last_sequence = sequence;
11650            envelopes.push(NodeEventEnvelope { sequence, event });
11651        }
11652        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11653        drop(history);
11654    }
11655
11656    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11657    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11658    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11659    /// counter every other event kind shares, never a counter of its own --
11660    /// `queue_connection_event`/`queue_connection_event_batch` compare
11661    /// `envelope.sequence` against one `discard_events_through` watermark
11662    /// regardless of which broadcast channel it arrived on, so a
11663    /// per-channel counter would desynchronize that comparison.
11664    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11665        let mut history = self
11666            .history
11667            .lock()
11668            .unwrap_or_else(|poisoned| poisoned.into_inner());
11669        let sequence = history
11670            .last_sequence
11671            .checked_add(1)
11672            .expect("node event sequence exhausted");
11673        history.last_sequence = sequence;
11674        drop(history);
11675        // Debug, not info/warn -- this fires on every single chunk, but it
11676        // is exactly the counter the next drop-measurement pass needs: what
11677        // the node actually emitted (`source_sequence`, `kind`) versus what
11678        // a harness subscriber's own forwarder later logs having received
11679        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11680        // event"), so a gap between the two logs names itself instead of
11681        // needing a new counter added ad hoc.
11682        tracing::debug!(
11683            source_sequence = chunk.source_sequence,
11684            kind = agent_stream_chunk_kind_label(&chunk.kind),
11685            envelope_sequence = sequence,
11686            "publishing agent stream chunk",
11687        );
11688        let envelope = NodeEventEnvelope {
11689            sequence,
11690            event: NodeEvent::AgentStream { address, chunk },
11691        };
11692        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11693    }
11694
11695    fn current_sequence(&self) -> u64 {
11696        self.history
11697            .lock()
11698            .unwrap_or_else(|poisoned| poisoned.into_inner())
11699            .last_sequence
11700    }
11701
11702    fn controller_state(&self) -> Option<ControllerState> {
11703        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11704        let lease = controller.as_ref().copied()?;
11705        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11706        if remaining.is_zero() {
11707            *controller = None;
11708            return None;
11709        }
11710        Some(ControllerState {
11711            connection_id: lease.connection_id,
11712            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11713        })
11714    }
11715
11716    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11717        if role != ClientRole::Operator {
11718            tracing::warn!(
11719                node_id = %self.node_id,
11720                connection_id,
11721                "controller lease rejected: observer connections cannot acquire control",
11722            );
11723            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11724        }
11725        let lease_ms = if lease_ms == 0 {
11726            DEFAULT_CONTROLLER_LEASE_MS
11727        } else {
11728            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11729        };
11730        let now = Instant::now();
11731        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11732        if let Some(active) = controller.as_ref() {
11733            if active.connection_id != connection_id && active.expires_at > now {
11734                tracing::warn!(
11735                    node_id = %self.node_id,
11736                    connection_id,
11737                    holder_connection_id = active.connection_id,
11738                    "controller lease rejected: another operator holds it",
11739                );
11740                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11741            }
11742        }
11743        // A reconnect (this connection already holds the lease, renewing
11744        // before expiry) happens on a periodic cadence; logging it at INFO
11745        // every renewal would be exactly the heartbeat chatter this crate
11746        // family's level discipline forbids. A genuine attach -- the
11747        // controller identity actually changing -- is a state change and
11748        // gets INFO; a same-connection renewal gets DEBUG.
11749        let is_new_attach = match controller.as_ref() {
11750            Some(active) => active.connection_id != connection_id,
11751            None => true,
11752        };
11753        *controller = Some(ControllerLease {
11754            connection_id,
11755            expires_at: now + Duration::from_millis(lease_ms),
11756        });
11757        drop(controller);
11758        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11759        if is_new_attach {
11760            tracing::info!(
11761                node_id = %self.node_id,
11762                connection_id,
11763                lease_ms,
11764                "controller lease acquired",
11765            );
11766        } else {
11767            tracing::debug!(
11768                node_id = %self.node_id,
11769                connection_id,
11770                lease_ms,
11771                "controller lease renewed",
11772            );
11773        }
11774        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11775        Ok(state)
11776    }
11777
11778    fn release_controller(&self, connection_id: u64) -> bool {
11779        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11780        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11781        if released {
11782            *controller = None;
11783        }
11784        drop(controller);
11785        if released {
11786            tracing::info!(
11787                node_id = %self.node_id,
11788                connection_id,
11789                "controller lease released",
11790            );
11791            self.publish(NodeEvent::ControllerChanged { controller: None });
11792        }
11793        released
11794    }
11795
11796    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11797    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11798    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11799    /// provider session; it only cancels the orphan timer. Disconnect/reload
11800    /// draft kills are owned by HQ and arm only on a real client WS close.
11801    /// ACP path of launch options: once the agent advertises its config options, write the
11802    /// pending model / effort through session/set_config_option (session/set_model when the
11803    /// agent has no model option). Gaps are logged, never forced.
11804    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11805        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11806            return;
11807        };
11808        let id = address.session.instance_id;
11809        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11810            return;
11811        };
11812        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11813            .iter()
11814            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11815            .collect();
11816        let (writes, set_model, effort_gap) =
11817            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11818        if set_model {
11819            if let Some(model_id) = model.clone() {
11820                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11821                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11822                }
11823            }
11824        }
11825        for (option_id, value_json) in writes {
11826            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11827                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11828            }
11829        }
11830        if effort_gap {
11831            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11832        }
11833        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11834    }
11835
11836    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11837    /// answer every session/request_permission with approve.
11838    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11839        let id = address.session.instance_id;
11840        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11841        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11842        match provider_event {
11843            ProviderEvent::ModeChanged { mode_id, available } => {
11844                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11845                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11846                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11847                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11848                    }
11849                }
11850            }
11851            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11852                match self.dispatch(ControlCommand::ResolveInteraction {
11853                    instance_id: id,
11854                    generation: address.session.generation,
11855                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11856                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11857                }) {
11858                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11859                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11860                }
11861            }
11862            ProviderEvent::SessionEnded { .. } => crate::auto_permit::forget_acp(id.0),
11863            _ => {}
11864        }
11865    }
11866
11867    fn mark_spawn_provisional(&self, address: SessionAddress) {
11868        let id = address.session.instance_id;
11869        self.provisional_spawns
11870            .lock()
11871            .unwrap_or_else(|poisoned| poisoned.into_inner())
11872            .insert(id, (address, Instant::now()));
11873        tracing::info!(
11874            node_id = %self.node_id,
11875            instance_id = id.0,
11876            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11877            "draft spawn; waiting for HQ view",
11878        );
11879    }
11880
11881    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11882        let removed = self
11883            .provisional_spawns
11884            .lock()
11885            .unwrap_or_else(|poisoned| poisoned.into_inner())
11886            .remove(&instance_id);
11887        if removed.is_some() {
11888            tracing::info!(
11889                node_id = %self.node_id,
11890                instance_id = instance_id.0,
11891                "draft spawn claimed by HQ view",
11892            );
11893        }
11894    }
11895
11896    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11897        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11898        let mut map = self
11899            .provisional_spawns
11900            .lock()
11901            .unwrap_or_else(|poisoned| poisoned.into_inner());
11902        let due: Vec<AgentInstanceId> = map
11903            .iter()
11904            .filter(|(_, (_, at))| at.elapsed() >= grace)
11905            .map(|(id, _)| *id)
11906            .collect();
11907        due.into_iter()
11908            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11909            .collect()
11910    }
11911
11912    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11913        let instance_id = address.session.instance_id;
11914        tracing::warn!(
11915            node_id = %self.node_id,
11916            instance_id = instance_id.0,
11917            workspace = %address.workspace_id,
11918            reason = "attach_never_arrived",
11919            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11920            "draft spawn never viewed; stopping PTY",
11921        );
11922        let _ = self.dispatch(ControlCommand::Stop {
11923            instance_id,
11924            force: true,
11925        });
11926        let _ = self.remove_session(&address).await;
11927    }
11928
11929    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11930        if role != ClientRole::Operator {
11931            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11932        }
11933        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11934        let now = Instant::now();
11935        match controller.as_ref().copied() {
11936            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11937            Some(active) if active.expires_at <= now => {
11938                *controller = None;
11939                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11940            }
11941            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11942        }
11943    }
11944
11945    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11946        if !self
11947            .workspaces
11948            .read()
11949            .unwrap_or_else(|poisoned| poisoned.into_inner())
11950            .contains_key(&address.workspace_id)
11951        {
11952            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11953        }
11954        let binding = self
11955            .session_bindings
11956            .lock()
11957            .unwrap_or_else(|poisoned| poisoned.into_inner())
11958            .get(&address.session.instance_id)
11959            .cloned()
11960            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11961        if binding.workspace_id != address.workspace_id {
11962            return Err(failure(
11963                NodeFailureCode::SessionWorkspaceMismatch,
11964                "session belongs to another workspace",
11965            ));
11966        }
11967        if binding.generation != address.session.generation {
11968            return Err(failure(
11969                NodeFailureCode::StaleGeneration,
11970                &format!(
11971                    "stale session generation: expected {}, received {}",
11972                    binding.generation.0,
11973                    address.session.generation.0,
11974                ),
11975            ));
11976        }
11977        let snapshot = self.handle.snapshot();
11978        let Some(current) = snapshot
11979            .sessions
11980            .iter()
11981            .find(|item| item.instance_id == address.session.instance_id)
11982        else {
11983            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11984        };
11985        if current.generation != address.session.generation {
11986            return Err(failure(
11987                NodeFailureCode::StaleGeneration,
11988                &format!(
11989                    "stale session generation: expected {}, received {}",
11990                    current.generation.0,
11991                    address.session.generation.0,
11992                ),
11993            ));
11994        }
11995        Ok(current.agent_id.clone())
11996    }
11997
11998    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11999        self.dispatch_envelope(self.prepare_command(command))
12000    }
12001
12002    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12003        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12004        CommandEnvelope {
12005            id: CommandId(id),
12006            command,
12007        }
12008    }
12009
12010    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12011        let command_id = envelope.id;
12012        self.handle
12013            .dispatch(envelope)
12014            .map_err(|error| match error {
12015                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12016                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12017            })?;
12018        Ok(command_id)
12019    }
12020
12021    /// Records the kernel's own reason for asynchronously refusing
12022    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12023    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12024    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12025    /// first, and if the map is still at capacity after that, the single
12026    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12027    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12028    /// is always the oldest.
12029    fn record_rejected_command(
12030        &self,
12031        command_id: CommandId,
12032        message: String,
12033        unsupported_transport: bool,
12034    ) {
12035        let now = Instant::now();
12036        let mut reasons = self.rejected_command_reasons.lock()
12037            .unwrap_or_else(|poisoned| poisoned.into_inner());
12038        reasons.retain(|_, reason| reason.expires_at > now);
12039        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12040            if let Some(&oldest) = reasons.keys().next() {
12041                reasons.remove(&oldest);
12042            }
12043        }
12044        reasons.insert(
12045            command_id,
12046            RejectedCommandReason {
12047                message,
12048                unsupported_transport,
12049                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12050            },
12051        );
12052    }
12053
12054    /// Removes and returns the kernel's reason for refusing `command_id`,
12055    /// if the drive loop has recorded one and it has not expired -- see
12056    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12057    /// wait loop calls this instead of blindly polling out its whole
12058    /// deadline when the command it is waiting on turns out to have been
12059    /// refused rather than merely slow.
12060    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12061        let now = Instant::now();
12062        let mut reasons = self.rejected_command_reasons.lock()
12063            .unwrap_or_else(|poisoned| poisoned.into_inner());
12064        match reasons.remove(&command_id) {
12065            Some(reason) if reason.expires_at > now => Some(reason),
12066            _ => None,
12067        }
12068    }
12069
12070    async fn dispatch_bounded(
12071        &self,
12072        command: ControlCommand,
12073        timeout_duration: Duration,
12074    ) -> Result<CommandId, NodeFailure> {
12075        let deadline = Instant::now() + timeout_duration;
12076        loop {
12077            match self.dispatch(command.clone()) {
12078                Ok(command_id) => return Ok(command_id),
12079                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12080                    sleep(Duration::from_millis(2)).await;
12081                }
12082                Err(error) => return Err(error),
12083            }
12084        }
12085    }
12086
12087    fn internal_session_snapshot(
12088        &self,
12089        address: &SessionAddress,
12090    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12091        self.validate_address(address)?;
12092        self.handle
12093            .snapshot()
12094            .sessions
12095            .clone()
12096            .into_iter()
12097            .find(|session| {
12098                session.instance_id == address.session.instance_id
12099                    && session.generation == address.session.generation
12100            })
12101            .ok_or_else(|| {
12102                failure(
12103                    NodeFailureCode::UnknownSession,
12104                    "session snapshot is unavailable",
12105                )
12106            })
12107    }
12108
12109    /// Confirms `address` names a live session spawned over the ACP
12110    /// transport and returns its current snapshot -- the gate every
12111    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12112    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12113    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12114    /// the session's own recorded `TransportKind` does, and that lives on
12115    /// the live snapshot, not the binding.
12116    fn require_acp_session(
12117        &self,
12118        address: &SessionAddress,
12119    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12120        let snapshot = self.internal_session_snapshot(address)?;
12121        if snapshot.transport != TransportKind::Acp {
12122            return Err(failure(
12123                NodeFailureCode::UnsupportedTransport,
12124                "this verb requires a session spawned over the ACP transport",
12125            ));
12126        }
12127        Ok(snapshot)
12128    }
12129
12130    async fn dispatch_history_bounded(
12131        &self,
12132        address: &SessionAddress,
12133        command: ControlCommand,
12134        operation: HistoryOperation,
12135    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12136        self.validate_address(address)?;
12137        let started_after = self.current_sequence();
12138        let command_id = self
12139            .dispatch_bounded(
12140                command,
12141                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12142            )
12143            .await?;
12144        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12145        let mut scan_after = started_after;
12146        let mut accepted_after = None;
12147        loop {
12148            let events = self
12149                .history
12150                .lock()
12151                .unwrap_or_else(|poisoned| poisoned.into_inner())
12152                .events
12153                .iter()
12154                .filter(|envelope| envelope.sequence > scan_after)
12155                .cloned()
12156                .collect::<Vec<_>>();
12157            for envelope in events {
12158                scan_after = scan_after.max(envelope.sequence);
12159                let NodeEvent::Control {
12160                    address: event_address,
12161                    event,
12162                } = envelope.event
12163                else {
12164                    continue;
12165                };
12166                if event_address != *address {
12167                    continue;
12168                }
12169                if event.command_id == Some(command_id) {
12170                    match &event.event {
12171                        ControlEventKind::CommandRejected { .. } => {
12172                            return Err(failure(
12173                                NodeFailureCode::InvalidRequest,
12174                                "history request was rejected",
12175                            ));
12176                        }
12177                        ControlEventKind::HistoryRequested {
12178                            operation: requested,
12179                            ..
12180                        } if requested == &operation => {
12181                            accepted_after = Some(envelope.sequence);
12182                            continue;
12183                        }
12184                        _ => {}
12185                    }
12186                }
12187                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12188                    match (&operation, &event.event) {
12189                        (
12190                            HistoryOperation::Discover { .. },
12191                            ControlEventKind::HistoryDiscovered { .. },
12192                        )
12193                        | (
12194                            HistoryOperation::Load { .. },
12195                            ControlEventKind::HistoryLoaded { .. },
12196                        ) => return self.internal_session_snapshot(address),
12197                        (_, ControlEventKind::HistoryFailed { .. }) => {
12198                            return Err(failure(
12199                                NodeFailureCode::BackendOperationFailed,
12200                                "history operation failed",
12201                            ));
12202                        }
12203                        _ => {}
12204                    }
12205                }
12206            }
12207            if Instant::now() >= deadline {
12208                return Err(failure(
12209                    NodeFailureCode::BackendBusy,
12210                    if accepted_after.is_some() {
12211                        "history operation did not settle before the bounded deadline"
12212                    } else {
12213                        "history operation was not accepted before the bounded deadline"
12214                    },
12215                ));
12216            }
12217            sleep(Duration::from_millis(2)).await;
12218        }
12219    }
12220
12221    async fn discover_history(
12222        &self,
12223        address: &SessionAddress,
12224        limit: u16,
12225    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12226        let query = HistoryQuery {
12227            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12228            limit,
12229        };
12230        query
12231            .validate()
12232            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12233        let snapshot = self
12234            .dispatch_history_bounded(
12235                address,
12236                ControlCommand::DiscoverHistory {
12237                    instance_id: address.session.instance_id,
12238                    query: query.clone(),
12239                },
12240                HistoryOperation::Discover { query },
12241            )
12242            .await?;
12243        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12244            return Err(failure(
12245                NodeFailureCode::BackendOperationFailed,
12246                "history discovery settled into an invalid state",
12247            ));
12248        }
12249        Ok(snapshot.history.candidates)
12250    }
12251
12252    async fn load_history(
12253        &self,
12254        address: &SessionAddress,
12255        candidate_id: String,
12256    ) -> Result<HistorySessionRecord, NodeFailure> {
12257        validate_candidate_id(&candidate_id)
12258            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12259        let snapshot = self
12260            .dispatch_history_bounded(
12261                address,
12262                ControlCommand::LoadHistory {
12263                    instance_id: address.session.instance_id,
12264                    candidate_id: candidate_id.clone(),
12265                },
12266                HistoryOperation::Load {
12267                    candidate_id: candidate_id.clone(),
12268                },
12269            )
12270            .await?;
12271        if snapshot.history.pending.is_some()
12272            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12273        {
12274            return Err(failure(
12275                NodeFailureCode::BackendOperationFailed,
12276                "history load settled into an invalid state",
12277            ));
12278        }
12279        snapshot.history.loaded.ok_or_else(|| {
12280            failure(
12281                NodeFailureCode::BackendOperationFailed,
12282                "history load produced no bounded session",
12283            )
12284        })
12285    }
12286
12287    /// `allow_clean_detachment`: `false` for the on-demand
12288    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12289    /// managed record must stay untouched, still live, for the whole call);
12290    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12291    /// reacts to the very clean-exit event that detaches the record out from
12292    /// under this same call — see `session_record_export_target_matches`.
12293    async fn export_context_pack_for_session_record(
12294        &self,
12295        record_id: &SessionRecordId,
12296        session: &SessionAddress,
12297        allow_clean_detachment: bool,
12298    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12299        // C2 replaces a relayed failure's prose with a canned per-code
12300        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12301        // distinct refusals are indistinguishable to anyone downstream --
12302        // and a refusal here expires a continuation, which keeps no field
12303        // for why. The prose stays on the node, where it was written.
12304        let outcome = self
12305            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12306            .await;
12307        if let Err(failure) = &outcome {
12308            tracing::warn!(
12309                record = %record_id,
12310                code = ?failure.code,
12311                reason = %failure.message,
12312                "session-record context-pack export refused",
12313            );
12314        }
12315        outcome
12316    }
12317
12318    async fn export_context_pack_for_session_record_inner(
12319        &self,
12320        record_id: &SessionRecordId,
12321        session: &SessionAddress,
12322        allow_clean_detachment: bool,
12323    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12324        // Several steps here refuse with the same BackendOperationFailed
12325        // code and no prose of their own, so which step failed is
12326        // otherwise unrecoverable -- and a refusal here expires a
12327        // continuation.
12328        let step = |name: &'static str| move |failure: NodeFailure| {
12329            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12330            failure
12331        };
12332        let record = self.record(record_id).map_err(step("record-lookup"))?;
12333        // `provider_session` is `Some` only for a provider whose resolved
12334        // runtime policy admits live identity verification (raw PTY plus a
12335        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12336        // The PTY-sidecar-observation shape (no fleet provider today, but
12337        // capability-keyed rather than vendor-locked) hardcodes
12338        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12339        // so its records go straight to `Live` with `provider_session: None`
12340        // and stay that way for the record's entire life -- that is this
12341        // shape's permanent resting state, not a race with observation, and
12342        // `discover_history`/`load_history` below do not need an identity to
12343        // do their job for it. `IdentityPending` is the actual race (an
12344        // identity-admitted provider whose observation has not landed yet):
12345        // it keeps refusing here rather than falling back, because that
12346        // provider's own history the fallback would otherwise trust is not
12347        // guaranteed to be exact without the identity it is still waiting on.
12348        let identity = match record.provider_session.clone() {
12349            Some(identity) => {
12350                if identity.key != ProviderSessionKey::SessionId {
12351                    return Err(step("provider-identity-key")(failure(
12352                        NodeFailureCode::BackendOperationFailed,
12353                        "managed session provider identity cannot bind history exactly",
12354                    )));
12355                }
12356                Some(identity)
12357            }
12358            None if record.state == ManagedSessionState::IdentityPending => {
12359                return Err(step("provider-session-identity")(failure(
12360                    NodeFailureCode::BackendOperationFailed,
12361                    "managed session has not yet observed its provider session identity",
12362                )));
12363            }
12364            None => None,
12365        };
12366        self.revalidate_session_record_context_export(
12367            &record,
12368            identity.as_ref(),
12369            session,
12370            allow_clean_detachment,
12371        ).map_err(step("revalidate-before-discovery"))?;
12372        let candidates = self
12373            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12374            .await
12375            .map_err(step("discover-history"))?;
12376        // With no verified identity to filter by, the discovered set must
12377        // still resolve to exactly one candidate: the same ambiguity refusal
12378        // an identity filter would otherwise produce is the only safety
12379        // property left available for this shape.
12380        let candidate_id = select_history_candidate(
12381            &candidates,
12382            identity.as_ref().map(|identity| identity.id.as_str()),
12383        ).map_err(step("unique-candidate"))?;
12384        let loaded = self.load_history(session, candidate_id.clone()).await
12385            .map_err(step("load-history"))?;
12386        if let Some(identity) = identity.as_ref() {
12387            if loaded.session_id != identity.id {
12388                return Err(failure(
12389                    NodeFailureCode::SessionRecordConflict,
12390                    "loaded history identity does not match the managed session record",
12391                ));
12392            }
12393        }
12394        self.revalidate_session_record_context_export(
12395            &record,
12396            identity.as_ref(),
12397            session,
12398            allow_clean_detachment,
12399        )?;
12400        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12401            .map_err(step("revalidate-loaded-history"))?;
12402        let pack = self.materialize_context_pack(session).await
12403            .map_err(step("materialize-context-pack"))?;
12404        self.commit_context_pack_for_session_record(
12405            &record,
12406            identity.as_ref(),
12407            session,
12408            &candidate_id,
12409            &loaded,
12410            pack,
12411            allow_clean_detachment,
12412        )
12413    }
12414
12415    async fn export_context_pack(
12416        &self,
12417        address: &SessionAddress,
12418    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12419        let pack = self.materialize_context_pack(address).await?;
12420        self.commit_context_pack(pack)
12421    }
12422
12423    async fn materialize_context_pack(
12424        &self,
12425        address: &SessionAddress,
12426    ) -> Result<NodeContextPack, NodeFailure> {
12427        let snapshot = self.internal_session_snapshot(address)?;
12428        if !context_pack_source_status_is_usable(&snapshot.status) {
12429            return Err(failure(
12430                NodeFailureCode::InvalidRequest,
12431                "context export requires a running or successfully exited source session",
12432            ));
12433        }
12434        if snapshot.history.pending.is_some()
12435            || snapshot.history.loaded_candidate_id.is_none()
12436        {
12437            return Err(failure(
12438                NodeFailureCode::InvalidRequest,
12439                "context export requires settled loaded history",
12440            ));
12441        }
12442        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12443            failure(
12444                NodeFailureCode::InvalidRequest,
12445                "context export requires settled loaded history",
12446            )
12447        })?;
12448        let source_provider = snapshot.agent_id.clone();
12449        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12450        let repository = self
12451            .context_pack_repository(&address.workspace_id)
12452            .await?;
12453        let current = self.internal_session_snapshot(address)?;
12454        if !context_pack_source_status_is_usable(&current.status)
12455            || current.agent_id != source_provider
12456            || current.history.pending.is_some()
12457            || current.history.loaded_candidate_id != loaded_candidate_id
12458            || current.history.loaded.as_ref() != Some(&loaded)
12459        {
12460            return Err(failure(
12461                NodeFailureCode::ContextPackMaterializationFailed,
12462                "context source changed during bounded repository capture",
12463            ));
12464        }
12465        NodeContextPack::export_with_repository(
12466            ContextPackLineageReceipt {
12467                source_node_id: self.node_id.clone(),
12468                source_session: address.clone(),
12469                source_provider,
12470            },
12471            &loaded,
12472            Some(repository),
12473        )
12474        .map_err(|_| {
12475            failure(
12476                NodeFailureCode::ContextPackMaterializationFailed,
12477                "bounded context pack export failed",
12478            )
12479        })
12480    }
12481
12482    /// Write-through to the durable [`ContextPackStore`], if one is
12483    /// configured (`state_path` is `Some`). A node with no durable state
12484    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12485    /// open at all and this is a no-op, matching `persist_state`'s own
12486    /// "durability optional, degrade gracefully when unconfigured" idiom.
12487    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12488        let mut store = self
12489            .context_pack_store
12490            .lock()
12491            .unwrap_or_else(|poisoned| poisoned.into_inner());
12492        let Some(store) = store.as_mut() else {
12493            return Ok(());
12494        };
12495        store.commit(pack).map_err(|_| {
12496            failure(
12497                NodeFailureCode::ContextPackMaterializationFailed,
12498                "durable context pack commit failed",
12499            )
12500        })
12501    }
12502
12503    fn commit_context_pack(
12504        &self,
12505        pack: NodeContextPack,
12506    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12507        self.commit_context_pack_durably(&pack)?;
12508        self.context_catalog
12509            .write()
12510            .unwrap_or_else(|poisoned| poisoned.into_inner())
12511            .insert(pack)
12512            .map_err(|_| {
12513                failure(
12514                    NodeFailureCode::ContextPackMaterializationFailed,
12515                    "context pack catalog is full",
12516                )
12517            })
12518    }
12519
12520    fn commit_context_pack_for_session_record(
12521        &self,
12522        expected: &ManagedSessionRecord,
12523        identity: Option<&ProviderSessionIdentity>,
12524        session: &SessionAddress,
12525        candidate_id: &str,
12526        loaded: &HistorySessionRecord,
12527        pack: NodeContextPack,
12528        allow_clean_detachment: bool,
12529    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12530        let current_root = self.workspace_root(&expected.workspace_id)?;
12531        let _transaction = self
12532            .state_transaction
12533            .lock()
12534            .unwrap_or_else(|poisoned| poisoned.into_inner());
12535        let records = self
12536            .session_records
12537            .lock()
12538            .unwrap_or_else(|poisoned| poisoned.into_inner());
12539        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12540            failure(
12541                NodeFailureCode::SessionRecordConflict,
12542                "managed session record disappeared during context export",
12543            )
12544        })?;
12545        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12546            return Err(failure(
12547                NodeFailureCode::SessionRecordConflict,
12548                "managed session record changed during context export",
12549            ));
12550        }
12551        let bindings = self
12552            .session_bindings
12553            .lock()
12554            .unwrap_or_else(|poisoned| poisoned.into_inner());
12555        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12556            failure(
12557                NodeFailureCode::SessionRecordConflict,
12558                "managed session runtime binding disappeared during context export",
12559            )
12560        })?;
12561        let runtime = self
12562            .handle
12563            .snapshot()
12564            .sessions
12565            .iter()
12566            .find(|snapshot| {
12567                snapshot.instance_id == session.session.instance_id
12568                    && snapshot.generation == session.session.generation
12569            })
12570            .cloned()
12571            .ok_or_else(|| {
12572                failure(
12573                    NodeFailureCode::SessionRecordConflict,
12574                    "managed session runtime disappeared during context export",
12575                )
12576            })?;
12577        let receipt = pack.receipt();
12578        if !session_record_context_export_binding_is_exact(
12579            expected,
12580            current,
12581            identity,
12582            session,
12583            binding,
12584            &runtime.agent_id,
12585            &current_root,
12586            allow_clean_detachment,
12587        ) || !session_record_context_export_source_is_usable(
12588            current,
12589            &runtime.status,
12590            allow_clean_detachment,
12591        ) || runtime.history.pending.is_some()
12592            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12593            || runtime.history.loaded.as_ref() != Some(loaded)
12594            || receipt.lineage.source_node_id != self.node_id
12595            || receipt.lineage.source_session != *session
12596            || receipt.lineage.source_provider != current.provider
12597            || receipt.source_message_count != loaded.message_count
12598        {
12599            return Err(failure(
12600                NodeFailureCode::SessionRecordConflict,
12601                "managed session source changed before context catalog commit",
12602            ));
12603        }
12604        self.commit_context_pack_durably(&pack)?;
12605        self.context_catalog
12606            .write()
12607            .unwrap_or_else(|poisoned| poisoned.into_inner())
12608            .insert(pack)
12609            .map_err(|_| {
12610                failure(
12611                    NodeFailureCode::ContextPackMaterializationFailed,
12612                    "context pack catalog is full",
12613                )
12614            })
12615    }
12616
12617    async fn context_pack_repository(
12618        &self,
12619        workspace_id: &WorkspaceId,
12620    ) -> Result<ContextPackRepository, NodeFailure> {
12621        let first = self
12622            .context_pack_repository_observation(workspace_id)
12623            .await?;
12624        let second = self
12625            .context_pack_repository_observation(workspace_id)
12626            .await?;
12627        stable_context_pack_repository(first, second)
12628    }
12629
12630    async fn context_pack_repository_observation(
12631        &self,
12632        workspace_id: &WorkspaceId,
12633    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12634        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12635        let head = if git.is_repository {
12636            let root = self.workspace_root(workspace_id)?;
12637            match resolve_base_commit_with_timeout(
12638                &root,
12639                "HEAD",
12640                GIT_COMMAND_TIMEOUT_MS,
12641            )
12642            .await
12643            {
12644                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12645                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12646                    ContextPackRepositoryHead::Unborn
12647                }
12648                Err(_) => {
12649                    return Err(failure(
12650                        NodeFailureCode::ContextPackMaterializationFailed,
12651                        "context pack HEAD observation failed",
12652                    ));
12653                }
12654            }
12655        } else {
12656            ContextPackRepositoryHead::NotRepository
12657        };
12658        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12659        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12660            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12661                failure(
12662                    NodeFailureCode::InvalidRepositoryPath,
12663                    "context pack selected file path is invalid",
12664                )
12665            })?;
12666            match self.read_workspace_file(workspace_id.clone(), path).await {
12667                Ok(WorkspaceFileRead {
12668                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12669                    ..
12670                }) => files.push(ContextPackRepositoryFileSource::utf8(
12671                    selected_path,
12672                    text,
12673                    byte_len,
12674                )),
12675                Ok(WorkspaceFileRead {
12676                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12677                    ..
12678                }) => files.push(ContextPackRepositoryFileSource::skipped(
12679                    selected_path,
12680                    ContextPackSelectedFileSkipReason::NonUtf8,
12681                    Some(byte_len),
12682                )),
12683                Ok(WorkspaceFileRead {
12684                    content: WorkspaceFileContent::TooLarge { .. },
12685                    ..
12686                }) => files.push(ContextPackRepositoryFileSource::skipped(
12687                    selected_path,
12688                    ContextPackSelectedFileSkipReason::TooLarge,
12689                    None,
12690                )),
12691                Err(error) => {
12692                    let reason = if matches!(
12693                        error.code,
12694                        NodeFailureCode::InvalidRepositoryPath
12695                            | NodeFailureCode::RepositoryFileNotRegular
12696                            | NodeFailureCode::RepositoryPathUnsafe
12697                    ) {
12698                        ContextPackSelectedFileSkipReason::Unsafe
12699                    } else {
12700                        ContextPackSelectedFileSkipReason::Unavailable
12701                    };
12702                    files.push(ContextPackRepositoryFileSource::skipped(
12703                        selected_path,
12704                        reason,
12705                        None,
12706                    ));
12707                }
12708            }
12709        }
12710        Ok(ContextPackRepositoryObservation { head, git, files })
12711    }
12712
12713    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12714        let referenced_by_binding = self
12715            .session_bindings
12716            .lock()
12717            .unwrap_or_else(|poisoned| poisoned.into_inner())
12718            .values()
12719            .any(|binding| {
12720                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12721            });
12722        let referenced_by_record = self
12723            .session_records
12724            .lock()
12725            .unwrap_or_else(|poisoned| poisoned.into_inner())
12726            .records
12727            .values()
12728            .any(|record| record.context_id.as_ref() == Some(context_id));
12729        let referenced_by_materialization = self
12730            .materializations
12731            .lock()
12732            .unwrap_or_else(|poisoned| poisoned.into_inner())
12733            .values()
12734            .any(|ownership| {
12735                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12736            });
12737        if referenced_by_binding
12738            || referenced_by_record
12739            || referenced_by_materialization
12740        {
12741            return Err(failure(
12742                NodeFailureCode::ContextPackBusy,
12743                "context pack is still referenced",
12744            ));
12745        }
12746        let mut catalog = self.context_catalog
12747            .write()
12748            .unwrap_or_else(|poisoned| poisoned.into_inner());
12749        if catalog.get(context_id).is_none() {
12750            return Err(failure(
12751                NodeFailureCode::UnknownContextPack,
12752                "context pack is unavailable",
12753            ));
12754        }
12755        self.spawn_idempotency
12756            .lock()
12757            .unwrap_or_else(|poisoned| poisoned.into_inner())
12758            .entries
12759            .retain(|_, entry| !entry.value.references_context(context_id));
12760        let removed = catalog.remove(context_id);
12761        debug_assert!(removed.is_some(), "validated context pack remains present");
12762        Ok(())
12763    }
12764
12765    async fn dispatch_input_bounded(
12766        &self,
12767        address: &SessionAddress,
12768        action: InputAction,
12769    ) -> Result<(), NodeFailure> {
12770        let started_after = self.current_sequence();
12771        self.pty_input_marks
12772            .lock()
12773            .unwrap_or_else(|poisoned| poisoned.into_inner())
12774            .insert(address.session.instance_id, (Instant::now(), false));
12775        self.pty_input_wake.notify_one();
12776        let command_id = self
12777            .dispatch_bounded(
12778                ControlCommand::SendInput {
12779                    instance_id: address.session.instance_id,
12780                    action,
12781                },
12782                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12783            )
12784            .await?;
12785        // A semantic prompt may spend the provider's full readiness budget before
12786        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12787        // but strictly beyond every readiness policy in this node's catalog.
12788        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12789        let mut scan_after = started_after;
12790        let mut accepted_after = None;
12791        loop {
12792            let events = self
12793                .history
12794                .lock()
12795                .unwrap_or_else(|poisoned| poisoned.into_inner())
12796                .events
12797                .iter()
12798                .filter(|envelope| envelope.sequence > scan_after)
12799                .cloned()
12800                .collect::<Vec<_>>();
12801            for envelope in events {
12802                scan_after = scan_after.max(envelope.sequence);
12803                let NodeEvent::Control {
12804                    address: event_address,
12805                    event,
12806                } = envelope.event
12807                else {
12808                    continue;
12809                };
12810                if event_address != *address {
12811                    continue;
12812                }
12813                if event.command_id == Some(command_id) {
12814                    match &event.event {
12815                        ControlEventKind::CommandRejected { message } => {
12816                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12817                        }
12818                        ControlEventKind::InputRequested { .. } => {
12819                            accepted_after = Some(envelope.sequence);
12820                            continue;
12821                        }
12822                        _ => {}
12823                    }
12824                }
12825                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12826                    match &event.event {
12827                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12828                        ControlEventKind::InputFailed { message, .. } => {
12829                            return Err(failure(
12830                                NodeFailureCode::BackendOperationFailed,
12831                                message,
12832                            ));
12833                        }
12834                        _ => {}
12835                    }
12836                }
12837            }
12838            if Instant::now() >= deadline {
12839                return Err(failure(
12840                    NodeFailureCode::BackendBusy,
12841                    if accepted_after.is_some() {
12842                        "input was accepted but did not settle before the bounded deadline"
12843                    } else {
12844                        "input was not accepted before the bounded deadline"
12845                    },
12846                ));
12847            }
12848            sleep(Duration::from_millis(2)).await;
12849        }
12850    }
12851
12852    async fn dispatch_resize_bounded(
12853        &self,
12854        address: &SessionAddress,
12855        size: gate4agent_types::TerminalSize,
12856    ) -> Result<(), NodeFailure> {
12857        let started_after = self.current_sequence();
12858        let command_id = self
12859            .dispatch_bounded(
12860                ControlCommand::Resize {
12861                    instance_id: address.session.instance_id,
12862                    size,
12863                },
12864                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12865            )
12866            .await?;
12867        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12868        let mut scan_after = started_after;
12869        let mut accepted_after = None;
12870        loop {
12871            let events = self
12872                .history
12873                .lock()
12874                .unwrap_or_else(|poisoned| poisoned.into_inner())
12875                .events
12876                .iter()
12877                .filter(|envelope| envelope.sequence > scan_after)
12878                .cloned()
12879                .collect::<Vec<_>>();
12880            for envelope in events {
12881                scan_after = scan_after.max(envelope.sequence);
12882                let NodeEvent::Control {
12883                    address: event_address,
12884                    event,
12885                } = envelope.event
12886                else {
12887                    continue;
12888                };
12889                if event_address != *address {
12890                    continue;
12891                }
12892                if event.command_id == Some(command_id) {
12893                    match &event.event {
12894                        ControlEventKind::CommandRejected { message } => {
12895                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12896                        }
12897                        ControlEventKind::ResizeRequested {
12898                            size: requested_size,
12899                            ..
12900                        } if *requested_size == size => {
12901                            accepted_after = Some(envelope.sequence);
12902                            continue;
12903                        }
12904                        _ => {}
12905                    }
12906                }
12907                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12908                    match &event.event {
12909                        ControlEventKind::Resized { size: settled_size }
12910                            if *settled_size == size =>
12911                        {
12912                            return Ok(());
12913                        }
12914                        ControlEventKind::ResizeFailed { message } => {
12915                            return Err(failure(
12916                                NodeFailureCode::BackendOperationFailed,
12917                                message,
12918                            ));
12919                        }
12920                        _ => {}
12921                    }
12922                }
12923            }
12924            if Instant::now() >= deadline {
12925                return Err(failure(
12926                    NodeFailureCode::BackendBusy,
12927                    if accepted_after.is_some() {
12928                        "resize was accepted but did not settle before the bounded deadline"
12929                    } else {
12930                        "resize was not accepted before the bounded deadline"
12931                    },
12932                ));
12933            }
12934            sleep(Duration::from_millis(2)).await;
12935        }
12936    }
12937
12938    async fn spawn_session(
12939        &self,
12940        workspace_id: WorkspaceId,
12941        provider: AgentId,
12942        mode: SessionMode,
12943        terminal_size: gate4agent_types::TerminalSize,
12944        initial_prompt: Option<String>,
12945    ) -> Result<SessionAddress, NodeFailure> {
12946        if self.managed_worktrees.lock()
12947            .unwrap_or_else(|poisoned| poisoned.into_inner())
12948            .lease_for_workspace(&workspace_id)
12949            .is_some()
12950        {
12951            return Err(failure(
12952                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12953                "managed worktree targets may only be spawned through their lease request",
12954            ));
12955        }
12956        self
12957            .spawn_session_with_deadline(
12958                workspace_id,
12959                provider.clone(),
12960                mode,
12961                terminal_size,
12962                initial_prompt,
12963                None,
12964                None,
12965                None,
12966                None,
12967                None,
12968                SpawnRecordPolicy::ProviderIdentityOnly,
12969                None,
12970                &[],
12971                None,
12972                // `NodeRequest::Spawn` (this method's only caller) carries no
12973                // approval-level selection of its own -- see
12974                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12975                // the one operator-wire path that can choose something other
12976                // than the axis default. Auto-permit may lower it where the agent
12977                // cannot offer its full-auto mode (claude ACP under root).
12978                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
12979                None,
12980                None,
12981            )
12982            .await
12983            .map(|(session, _runtime_policy)| session)
12984    }
12985
12986    async fn spawn_session_with_deadline(
12987        &self,
12988        workspace_id: WorkspaceId,
12989        provider: AgentId,
12990        mode: SessionMode,
12991        terminal_size: gate4agent_types::TerminalSize,
12992        initial_prompt: Option<String>,
12993        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12994        bundle: Option<ResolvedBundleReceipt>,
12995        context: Option<ResolvedContextPackReceipt>,
12996        managed_authority: Option<ManagedWorktreeLeaseId>,
12997        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12998        record_policy: SpawnRecordPolicy,
12999        deadline: Option<Instant>,
13000        required_capabilities: &[ProviderRuntimeCapability],
13001        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13002        approval_level: ApprovalLevel,
13003        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13004        // Opaque dig2browser station profile id (ids only). Feature
13005        // `dig2-station-probe`: after probe at resolve, spawn takes an
13006        // exclusive node-local lease; cleared on session end. Never
13007        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13008        // already happened on the receipt path).
13009        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13010    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13011        let reserved_lease = self.managed_worktrees.lock()
13012            .unwrap_or_else(|poisoned| poisoned.into_inner())
13013            .lease_for_workspace(&workspace_id);
13014        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13015            (Some(expected), Some(provided)) if expected == provided => {}
13016            (None, None) => {}
13017            _ => {
13018                return Err(failure(
13019                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13020                    "spawn lacks exact managed worktree lease authority",
13021                ));
13022            }
13023        }
13024        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13025        // bindings) on the same workspace cwd. Parallel path: managed worktree
13026        // spawn so each profile gets its own workspace_id.
13027        self.ensure_workspace_environment_profile_compatible(
13028            &workspace_id,
13029            environment_profile.as_ref(),
13030        )?;
13031        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13032            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13033            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13034            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13035            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13036        };
13037        // This session's own transport -- computed once, here, from `mode`,
13038        // and reused both to admit spawn (below) and, further down, to
13039        // compute the final `runtime_policy` this session actually operates
13040        // under (`provider_runtime::policy_for_transport`). Registration
13041        // reuses this exact value rather than recomputing it a second time.
13042        let transport = match mode {
13043            SessionMode::Pty => TransportKind::Pty,
13044            SessionMode::Inline => TransportKind::Pipe,
13045            SessionMode::Acp => TransportKind::Acp,
13046        };
13047        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13048        let acp_transport = self.provider_supports_acp_transport(&provider);
13049        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13050            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13051                NodeFailureCode::UnsupportedSpawnCapability,
13052                "pre-admitted provider runtime no longer satisfies spawn requirements",
13053            ))?;
13054            runtime_policy
13055        } else if let Some(deadline) = deadline {
13056            let remaining = spawn_deadline_remaining(deadline)?;
13057            timeout(
13058                remaining,
13059                self.admit_provider_runtime(&provider, runtime_requirement),
13060            )
13061            .await
13062            .map_err(|_| {
13063                failure(
13064                    NodeFailureCode::SpawnDeadlineExceeded,
13065                    "provider admission exceeded the spawn deadline",
13066                )
13067            })??
13068        } else {
13069            self.admit_provider_runtime(&provider, runtime_requirement).await?
13070        };
13071        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13072        // The single point where this session's `runtime_policy` becomes
13073        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13074        // branch above) only ever compute a PTY-terminal-verification-shaped
13075        // value, sound outright only for `TransportKind::Pty` (Pipe still
13076        // relies on that same value downstream -- see `policy_for_transport`'s
13077        // own doc for why). `policy_for_transport` is the explicit,
13078        // exhaustive match Item 1 asks for -- this is the ONE place its
13079        // output becomes the session's `runtime_policy`, so every later
13080        // reader (`ControlCommand::Start`, `session_bindings`, and every
13081        // engine gate that reads `state.runtime_policy`) sees the same,
13082        // correctly-computed value.
13083        let runtime_policy =
13084            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13085        if required_capabilities
13086            .iter()
13087            .any(|capability| !runtime_policy.admits(*capability))
13088        {
13089            return Err(failure(
13090                NodeFailureCode::UnsupportedSpawnCapability,
13091                "provider runtime does not admit a required spawn capability",
13092            ));
13093        }
13094        if let Some(deadline) = deadline {
13095            spawn_deadline_remaining(deadline)?;
13096        }
13097        self.ensure_binding_capacity()?;
13098        let mut working_directory = self.workspace_root(&workspace_id)?;
13099        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13100        let session = SessionKey {
13101            instance_id,
13102            generation: SessionGeneration(1),
13103        };
13104        let address = SessionAddress {
13105            workspace_id: workspace_id.clone(),
13106            session,
13107        };
13108        // This is the request-accepted checkpoint: admission (managed
13109        // worktree authority, runtime capability, dispatch deadline) already
13110        // passed and a concrete session identity is minted. It is a distinct
13111        // state from the process actually running (`ControlEventKind::Running`,
13112        // logged in `publish_control`) or the terminal outcome (`Exited`/
13113        // `Failed`, logged there too) -- this line exists so that gap is no
13114        // longer silent.
13115        tracing::info!(
13116            node_id = %self.node_id,
13117            workspace_id = %workspace_id,
13118            provider = %provider,
13119            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13120            session = ?address.session,
13121            "session spawn request accepted",
13122        );
13123        let mut prepared_spawn = SpawnPreparer::prepare(
13124            self,
13125            SpawnPrepareRequest {
13126                instance_id,
13127                address: &address,
13128                provider: &provider,
13129                mode,
13130                environment_profile: environment_profile.as_ref(),
13131                bundle: bundle.as_ref(),
13132                context: context.as_ref(),
13133                managed_authority: managed_authority.as_ref(),
13134                harness_mcp,
13135                approval_level,
13136                network_allowlist: network_allowlist.as_ref(),
13137                browser_profile_id,
13138            },
13139        )?;
13140        let materialization_id = prepared_spawn.materialization_id.take();
13141        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13142        let record_id = self.bind_spawn_session_with_materialization(
13143            &address,
13144            provider.clone(),
13145            mode,
13146            runtime_policy,
13147            record_policy,
13148            environment_profile,
13149            bundle,
13150            context,
13151            materialization_id,
13152        )?;
13153        prepared_spawn.retain_session_guards();
13154        // `transport` was already computed above, alongside this session's
13155        // `runtime_policy`; reused here for `Register` rather than derived a
13156        // second time.
13157        let session_provider = provider.clone();
13158        let agent_id = provider;
13159        let register_command_id = match self.dispatch_bounded(
13160            ControlCommand::Register {
13161                instance_id,
13162                agent_id,
13163                transport,
13164            },
13165            dispatch_timeout,
13166        )
13167        .await {
13168            Ok(command_id) => command_id,
13169            Err(error) => {
13170                if let Some(binding) = self.remove_binding(&address) {
13171                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13172                }
13173                if let Some(record_id) = record_id.as_ref() {
13174                    self.discard_record(record_id)?;
13175                }
13176                return Err(spawn_dispatch_error(error, deadline));
13177            }
13178        };
13179        let start_timeout = match spawn_dispatch_timeout(deadline) {
13180            Ok(timeout) => timeout,
13181            Err(error) => {
13182                let recovery = self
13183                    .rollback_spawn(
13184                        &address,
13185                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13186                    )
13187                    .await;
13188                return match recovery {
13189                    Ok(()) => {
13190                        if let Some(record_id) = record_id.as_ref() {
13191                            self.discard_record(record_id)?;
13192                        }
13193                        Err(error)
13194                    }
13195                    Err(recovery_error) => {
13196                        if let Some(record_id) = record_id.as_ref() {
13197                            self.mark_record_error(
13198                                record_id,
13199                                "spawn deadline elapsed and registration recovery failed",
13200                            )?;
13201                        }
13202                        Err(recovery_error)
13203                    }
13204                };
13205            }
13206        };
13207        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13208            if let Err(recovery_error) = self
13209                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13210                .await
13211            {
13212                return Err(recovery_error);
13213            }
13214            if let Some(record_id) = record_id.as_ref() {
13215                self.discard_record(record_id)?;
13216            }
13217            return Err(start_error);
13218        }
13219        // A session with its own m4a account runs in its own folder inside the workspace.
13220        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13221            working_directory = dir.to_string_lossy().into_owned();
13222        }
13223        let start_result = self
13224            .dispatch_bounded(
13225                ControlCommand::Start {
13226                    instance_id,
13227                    runtime_policy,
13228                    request: StartRequest {
13229                        working_directory,
13230                        terminal_size,
13231                        initial_prompt,
13232                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13233                        approval_level,
13234                    },
13235                },
13236                start_timeout,
13237            )
13238            .await;
13239        let start_command_id = match start_result {
13240            Ok(command_id) => command_id,
13241            Err(start_error) => {
13242                let start_error = spawn_dispatch_error(start_error, deadline);
13243                let recovery = self
13244                    .rollback_spawn(
13245                        &address,
13246                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13247                    )
13248                    .await;
13249                return match recovery {
13250                    Ok(()) => {
13251                        if let Some(record_id) = record_id.as_ref() {
13252                            self.discard_record(record_id)?;
13253                        }
13254                        Err(start_error)
13255                    }
13256                    Err(recovery_error) => {
13257                        let diagnostic = format!(
13258                            "start failed and registration recovery failed: {}",
13259                            recovery_error.message,
13260                        );
13261                        if let Some(record_id) = record_id.as_ref() {
13262                            self.mark_record_error(record_id, &diagnostic)?;
13263                        }
13264                        Err(failure(recovery_error.code, &diagnostic))
13265                    }
13266                };
13267            }
13268        };
13269
13270        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13271        loop {
13272            // `Register`/`Start` only fail synchronously for a port-level
13273            // ingress problem (see `dispatch_bounded`); the kernel refusing
13274            // either command's content -- an unsupported transport, an
13275            // unknown agent -- lands one drive-loop tick later, and no
13276            // session ever appears in `self.handle.snapshot()` for it to
13277            // notice below. Check for that named reason before falling
13278            // through to the blind presence poll, so a rejected spawn
13279            // reports why instead of just timing out.
13280            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13281                .or_else(|| self.take_rejected_command_reason(start_command_id))
13282            {
13283                self.rollback_spawn(
13284                    &address,
13285                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13286                ).await.map_err(|error| {
13287                        failure(
13288                            error.code,
13289                            &format!(
13290                                "spawn was rejected ({}) and registration recovery failed: {}",
13291                                reason.message, error.message
13292                            ),
13293                        )
13294                    })?;
13295                if let Some(record_id) = record_id.as_ref() {
13296                    self.discard_record(record_id)?;
13297                }
13298                let code = if reason.unsupported_transport {
13299                    NodeFailureCode::UnsupportedTransport
13300                } else {
13301                    NodeFailureCode::BackendOperationFailed
13302                };
13303                return Err(failure(code, &reason.message));
13304            }
13305            if let Some(current) = self
13306                .handle
13307                .snapshot()
13308                .sessions
13309                .iter()
13310                .find(|current| current.instance_id == instance_id)
13311            {
13312                if current.generation != session.generation {
13313                    let divergence = failure(
13314                        NodeFailureCode::StaleGeneration,
13315                        "spawn generation diverged",
13316                    );
13317                    return match self
13318                        .rollback_spawn(
13319                            &address,
13320                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13321                        )
13322                        .await
13323                    {
13324                        Ok(()) => {
13325                            if let Some(record_id) = record_id.as_ref() {
13326                                self.discard_record(record_id)?;
13327                            }
13328                            Err(divergence)
13329                        }
13330                        Err(recovery_error) => {
13331                            if let Some(record_id) = record_id.as_ref() {
13332                                self.mark_record_error(
13333                                    record_id,
13334                                    "spawn generation diverged and recovery failed",
13335                                )?;
13336                            }
13337                            Err(recovery_error)
13338                        }
13339                    };
13340                }
13341                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13342                    prepared_spawn.retain_launch_guards();
13343                    return Ok((address, runtime_policy));
13344                }
13345            }
13346            if Instant::now() >= commit_deadline {
13347                self.rollback_spawn(
13348                    &address,
13349                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13350                ).await.map_err(|error| {
13351                        failure(
13352                            error.code,
13353                            &format!(
13354                                "start did not commit and registration recovery failed: {}",
13355                                error.message
13356                            ),
13357                        )
13358                    })?;
13359                if let Some(record_id) = record_id.as_ref() {
13360                    self.discard_record(record_id)?;
13361                }
13362                return Err(failure(
13363                    if deadline.is_some() {
13364                        NodeFailureCode::SpawnDeadlineExceeded
13365                    } else {
13366                        NodeFailureCode::BackendBusy
13367                    },
13368                    "start did not commit before the bounded deadline; registration was removed",
13369                ));
13370            }
13371            sleep(Duration::from_millis(2)).await;
13372        }
13373    }
13374
13375    fn effective_spawn_runtime_policy(
13376        &self,
13377        runtime_policy: ProviderRuntimePolicy,
13378    ) -> ProviderRuntimePolicy {
13379        #[cfg(feature = "fixture")]
13380        let runtime_policy = if self.fixture_semantic_hook_policy {
13381            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13382                .expect("monitoring Hook fixture policy is internally valid")
13383        } else {
13384            runtime_policy
13385        };
13386        runtime_policy
13387    }
13388
13389    fn verify_harness_mcp_before_start(
13390        &self,
13391        prepared: Option<&PreparedHarnessMcpSpawn>,
13392    ) -> Result<(), NodeFailure> {
13393        prepared
13394            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13395            .transpose()
13396            .map(|_| ())
13397            .map_err(harness_mcp_failure)
13398    }
13399
13400    async fn rollback_spawn(
13401        &self,
13402        address: &SessionAddress,
13403        commit_timeout: Duration,
13404    ) -> Result<(), NodeFailure> {
13405        self.dispatch_bounded(
13406            ControlCommand::Remove {
13407                instance_id: address.session.instance_id,
13408            },
13409            commit_timeout,
13410        )
13411        .await?;
13412        self.wait_until_removed(address, commit_timeout).await?;
13413        if let Some(binding) = self.remove_binding(address) {
13414            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13415        }
13416        Ok(())
13417    }
13418
13419    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13420        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13421        self.dispatch_bounded(
13422            ControlCommand::Remove {
13423                instance_id: address.session.instance_id,
13424            },
13425            commit_timeout,
13426        )
13427            .await?;
13428        self.wait_until_removed(address, commit_timeout).await?;
13429        let removed = self.remove_binding(address);
13430        if let Some(binding) = removed.as_ref() {
13431            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13432        }
13433        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13434            let snapshot = self.managed_worktrees.lock()
13435                .unwrap_or_else(|poisoned| poisoned.into_inner())
13436                .release_session(
13437                    &lease_id,
13438                    self.incarnation_id,
13439                    address.session.instance_id,
13440                    address.session.generation,
13441                    unix_time_ms(),
13442                );
13443            if self.persist_state().is_err() {
13444                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13445            }
13446            if let Some(snapshot) = snapshot {
13447                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13448            }
13449            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13450        }
13451        Ok(())
13452    }
13453
13454    /// Polls until `address`'s session leaves the engine's own session map,
13455    /// or the bounded deadline expires.
13456    ///
13457    /// A `Remove` sent while the session is not yet in a terminal status
13458    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13459    /// once by the engine (`SessionStatus::allows_remove`) and
13460    /// then discarded -- nothing re-issues it on its own, so the removal
13461    /// that would satisfy this wait never arrives and the caller was
13462    /// guaranteed to time out even though the session settles microseconds
13463    /// later. So this re-dispatches on every poll tick: a session that
13464    /// settles mid-wait -- the common case right after a `Stop` -- still
13465    /// gets removed inside the same bounded deadline.
13466    ///
13467    /// It re-dispatches only once the status will ACCEPT the command. A
13468    /// rejected re-dispatch is not the harmless no-op this loop first
13469    /// assumed: every rejection publishes a `ControlEventKind::Command
13470    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13471    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13472    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13473    /// out to the harness and every attached client. Gated, the same
13474    /// teardown sends exactly one `Remove`, at the first tick the engine
13475    /// can act on it.
13476    ///
13477    /// The status is read from the snapshot this loop already takes, so
13478    /// the gate costs nothing extra. It races -- the status can move
13479    /// between the read and the dispatch -- and that is fine: a rare lost
13480    /// race is one rejection, and the next tick retries.
13481    ///
13482    /// Re-dispatch is separately rate-limited to
13483    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13484    /// leaves a second burst behind it. Once the session settles, every
13485    /// 2ms tick sent another `Remove` while the accepted one was still in
13486    /// flight, and each of those landed on an instance the engine had
13487    /// already dropped -- `agent instance … is not registered`, observed
13488    /// live as a tail of rejections immediately after the `session
13489    /// removed` line. The presence poll stays at 2ms so the wait still
13490    /// exits promptly; only the sending slows down.
13491    ///
13492    /// Rate-limited rather than once-only on purpose: the status gate
13493    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13494    /// others (a pending operation, an in-flight capability probe or
13495    /// history request) that clear on their own. A single shot would turn
13496    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13497    /// get their retries, just twenty of them across the deadline instead
13498    /// of a thousand.
13499    async fn wait_until_removed(
13500        &self,
13501        address: &SessionAddress,
13502        commit_timeout: Duration,
13503    ) -> Result<(), NodeFailure> {
13504        let deadline = Instant::now() + commit_timeout;
13505        let mut next_dispatch = Instant::now();
13506        loop {
13507            let snapshot = self.handle.snapshot();
13508            let status = snapshot
13509                .sessions
13510                .iter()
13511                .find(|session| session.instance_id == address.session.instance_id)
13512                .map(|session| session.status.clone());
13513            let Some(status) = status else {
13514                return Ok(());
13515            };
13516            let now = Instant::now();
13517            if now >= deadline {
13518                return Err(failure(
13519                    NodeFailureCode::BackendBusy,
13520                    "remove did not commit before the bounded deadline; session binding was retained",
13521                ));
13522            }
13523            if status.allows_remove() && now >= next_dispatch {
13524                let _ = self.dispatch(ControlCommand::Remove {
13525                    instance_id: address.session.instance_id,
13526                });
13527                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13528            }
13529            sleep(Duration::from_millis(2)).await;
13530        }
13531    }
13532
13533    fn resync(&self, after_sequence: u64) -> NodeResponse {
13534        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13535        let event_sequence = history.last_sequence;
13536        let oldest_available_sequence = history.replay_floor_sequence;
13537        let events = history
13538            .events
13539            .iter()
13540            .filter(|event| event.sequence > after_sequence)
13541            .cloned()
13542            .collect();
13543        drop(history);
13544        NodeResponse::Resync {
13545            event_sequence,
13546            oldest_available_sequence,
13547            snapshot: self.snapshot(),
13548            events,
13549        }
13550    }
13551}
13552
13553/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13554/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13555/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13556/// "name what crossed this boundary" reasoning, one layer upstream of it).
13557fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13558    match kind {
13559        AgentStreamChunkKindV1::Text { .. } => "text",
13560        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13561        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13562        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13563        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13564        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13565        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13566    }
13567}
13568
13569/// Serves this wire on the local endpoint, and -- when `call_home` names a
13570/// relay -- over an outbound connection to it as well.
13571///
13572/// The two live in one future rather than a fourth arm of `run`'s own
13573/// `select!` because they are one concern: this is where the node's wire
13574/// is served, and the direction a given connection was opened in does not
13575/// change what happens on it. It also means the existing graceful drain
13576/// below covers both without being duplicated.
13577///
13578/// Either side finishing ends the pair, which is correct: the listener
13579/// only returns on shutdown or a bind failure, and `call_home_loop` only
13580/// returns on shutdown -- a failed relay connection is retried inside it,
13581/// never propagated out.
13582async fn accept_connections(
13583    endpoint: &str,
13584    call_home: Option<std::net::SocketAddr>,
13585    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13586    shared: Arc<NodeShared>,
13587) -> Result<(), NodeServerError> {
13588    let mut connections = JoinSet::new();
13589    let result = match call_home {
13590        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13591        Some(relay) => {
13592            let listener =
13593                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13594            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13595            tokio::pin!(listener);
13596            tokio::pin!(dialler);
13597            tokio::select! {
13598                result = &mut listener => result,
13599                result = &mut dialler => result,
13600            }
13601        }
13602    };
13603    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13604    while !connections.is_empty() {
13605        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13606        if remaining.is_zero()
13607            || timeout(remaining, connections.join_next()).await.is_err()
13608        {
13609            break;
13610        }
13611    }
13612    connections.abort_all();
13613    while connections.join_next().await.is_some() {}
13614    result
13615}
13616
13617/// Backoff ladder for a relay that will not answer, capped rather than
13618/// unbounded: a node whose relay is down is not broken and should keep
13619/// trying forever, but it should not spend a core doing it.
13620const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13621
13622/// Dials the relay, serves this wire over the connection, and does it
13623/// again when the connection ends.
13624///
13625/// The node is the SERVER on the socket it just opened. That inversion is
13626/// the whole point: `serve_connection` is the same function the local
13627/// listener feeds, and it neither knows nor cares which end placed the
13628/// call. The one thing this direction adds is the preface -- the relay
13629/// accepted a socket and cannot know whose it is, so the node says its
13630/// name before the handshake it would otherwise begin with.
13631///
13632/// Never returns an error. A relay that is down, refusing, or speaking a
13633/// different protocol is a condition to retry, not a reason to take the
13634/// node's local endpoint down with it; the only way out is shutdown.
13635async fn call_home_loop(
13636    relay: std::net::SocketAddr,
13637    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13638    shared: Arc<NodeShared>,
13639) -> Result<(), NodeServerError> {
13640    let mut failures = 0_usize;
13641    loop {
13642        if shared.shutdown.load(Ordering::Acquire) {
13643            return Ok(());
13644        }
13645        let permit = tokio::select! {
13646            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13647                permit.map_err(|_| NodeServerError::PreauthClosed)?
13648            }
13649            _ = shared.shutdown_notify.notified() => return Ok(()),
13650        };
13651        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13652            Ok(()) => {
13653                // A clean end is the relay hanging up, which it does on
13654                // its own restart. Reconnect immediately rather than
13655                // serving the backoff a peer that is plainly there.
13656                failures = 0;
13657                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13658            }
13659            Err(error) => {
13660                failures = failures.saturating_add(1);
13661                tracing::warn!(
13662                    node_id = %shared.node_id,
13663                    %relay,
13664                    cause = %error,
13665                    attempt = failures,
13666                    "call-home connection failed",
13667                );
13668                let backoff = call_home_backoff(failures);
13669                tokio::select! {
13670                    _ = sleep(backoff) => {}
13671                    _ = shared.shutdown_notify.notified() => return Ok(()),
13672                }
13673            }
13674        }
13675    }
13676}
13677
13678/// Idle time before the first keepalive probe on a call-home socket.
13679const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13680/// Interval between keepalive probes once the link has gone quiet.
13681const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13682
13683fn call_home_keepalive() -> socket2::TcpKeepalive {
13684    let keepalive = socket2::TcpKeepalive::new()
13685        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13686        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13687    #[cfg(not(windows))]
13688    let keepalive = keepalive.with_retries(3);
13689    keepalive
13690}
13691
13692fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13693    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13694}
13695
13696/// Same keepalive profile for any node->relay socket (restream uplink).
13697pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13698    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13699}
13700
13701/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13702fn call_home_backoff(attempt: usize) -> Duration {
13703    Duration::from_millis(
13704        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13705    )
13706}
13707
13708
13709/// Dial the call-home relay. Tunnel addresses go through the in-process
13710/// WireGuard dialer when the userspace session is up.
13711async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13712    #[cfg(feature = "wireguard")]
13713    {
13714        let std_stream = tokio::task::spawn_blocking(move || {
13715            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13716        })
13717        .await
13718        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13719        .map_err(NodeServerError::CallHomeTransport)?;
13720        std_stream
13721            .set_nonblocking(true)
13722            .map_err(NodeServerError::CallHomeTransport)?;
13723        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13724    }
13725    #[cfg(not(feature = "wireguard"))]
13726    {
13727        tokio::net::TcpStream::connect(relay)
13728            .await
13729            .map_err(NodeServerError::CallHomeTransport)
13730    }
13731}
13732
13733/// One call-home attempt: connect, name this node, then serve.
13734async fn dial_relay_and_serve(
13735    relay: std::net::SocketAddr,
13736    identity: Option<&hatchery_enroll::NodeIdentity>,
13737    shared: Arc<NodeShared>,
13738    preauth_permit: OwnedSemaphorePermit,
13739) -> Result<(), NodeServerError> {
13740    let mut stream = dial_call_home(relay).await?;
13741    // Nagle off for the same reason the rest of this wire wants it off:
13742    // the traffic is small request/response frames, and coalescing them
13743    // adds latency to every one.
13744    let _ = stream.set_nodelay(true);
13745    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13746    // relay-side socket kill whose RST was lost) would otherwise leave this
13747    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13748    // fails and the reconnect loop never runs. Kernel keepalive turns that
13749    // half-open link into a read error within a bounded time.
13750    if let Err(error) = enable_call_home_keepalive(&stream) {
13751        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13752    }
13753    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13754        .await
13755        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13756    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13757    let mut session_token = None;
13758    if let Some(identity) = identity {
13759        // An enrolled relay answers the announce with a signed-call-home challenge; a
13760        // token-mode relay sends its wire hello instead. Peek to tell them apart
13761        // without consuming either.
13762        let mut magic = [0_u8; 4];
13763        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13764            .await
13765            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13766            .map_err(NodeServerError::CallHomeTransport)?;
13767        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13768            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13769            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13770                .await
13771                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13772                .map_err(NodeServerError::CallHomeTransport)?;
13773            let (answer, token) = identity
13774                .answer_challenge(shared.node_id.as_str(), &frame)
13775                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13776            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13777            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13778            session_token = Some(token);
13779        }
13780    }
13781    serve_connection(stream, shared, session_token, preauth_permit).await
13782}
13783
13784async fn accept_connections_inner(
13785    endpoint: &str,
13786    shared: Arc<NodeShared>,
13787    connections: &mut JoinSet<Result<(), NodeServerError>>,
13788) -> Result<(), NodeServerError> {
13789    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13790    loop {
13791        while connections.try_join_next().is_some() {}
13792        if shared.shutdown.load(Ordering::Acquire) {
13793            return Ok(());
13794        }
13795        let preauth_permit = tokio::select! {
13796            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13797                permit.map_err(|_| NodeServerError::PreauthClosed)?
13798            }
13799            _ = shared.shutdown_notify.notified() => return Ok(()),
13800        };
13801        let server = tokio::select! {
13802            result = listener.accept() => result?,
13803            _ = shared.shutdown_notify.notified() => return Ok(()),
13804        };
13805        let connection_shared = Arc::clone(&shared);
13806        connections.spawn(async move {
13807            serve_connection(server, connection_shared, None, preauth_permit).await
13808        });
13809        if shared.shutdown.load(Ordering::Acquire) {
13810            return Ok(());
13811        }
13812    }
13813}
13814
13815/// Serves one connection, whichever end opened it.
13816///
13817/// Generic over the stream rather than typed to `LocalServerStream`
13818/// because a call-home connection arrives on a `TcpStream` the node itself
13819/// dialled. Nothing in the body ever depended on the concrete type -- it
13820/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13821/// the whole of what welding "who dials" to "who serves" was costing.
13822async fn serve_connection<S>(
13823    mut pipe: S,
13824    shared: Arc<NodeShared>,
13825    session_token: Option<String>,
13826    preauth_permit: OwnedSemaphorePermit,
13827) -> Result<(), NodeServerError>
13828where
13829    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13830{
13831    // A signed call-home connection authenticates with its own per-connection token.
13832    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13833    let hello_frame = timeout(
13834        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13835        read_json_frame_limited_body_timeout(
13836            &mut pipe,
13837            MAX_NODE_HELLO_FRAME_BYTES,
13838            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13839        ),
13840    )
13841    .await
13842    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13843    let ClientFrame::Hello(hello) = hello_frame else {
13844        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13845    };
13846    if hello.build_stamp != BUILD_STAMP {
13847        return Err(NodeServerError::Handshake(format!(
13848            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13849            hello.build_stamp,
13850        )));
13851    }
13852    let compatibility = match hello.compatibility.as_ref() {
13853        Some(offer) => {
13854            let mut selected = node_compatibility_support(&shared)?
13855                .negotiate(offer)
13856                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13857            project_negotiated_provider_ids(&mut selected);
13858            Some(selected)
13859        }
13860        None => None,
13861    };
13862    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13863    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13864        (Some(offer), Some(selected)) => negotiated_auth_proof(
13865            auth_token,
13866            AuthDirection::Server,
13867            hello.role,
13868            &hello.client_nonce,
13869            &server_nonce,
13870            offer,
13871            selected,
13872        ),
13873        (None, None) => auth_proof(
13874            auth_token,
13875            AuthDirection::Server,
13876            hello.role,
13877            &hello.client_nonce,
13878            &server_nonce,
13879        ),
13880        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13881    }
13882    .map_err(NodeServerError::Authentication)?;
13883    write_json_frame_limited(
13884        &mut pipe,
13885        &ServerFrame::Challenge(ServerChallenge {
13886            build_stamp: BUILD_STAMP.to_owned(),
13887            server_nonce,
13888            server_proof,
13889            compatibility: compatibility.clone(),
13890        }),
13891        MAX_NODE_HELLO_FRAME_BYTES,
13892    )
13893    .await?;
13894    let authentication = timeout(
13895        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13896        read_json_frame_limited_body_timeout(
13897            &mut pipe,
13898            MAX_NODE_HELLO_FRAME_BYTES,
13899            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13900        ),
13901    )
13902    .await
13903    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13904    let ClientFrame::Authenticate(authentication) = authentication else {
13905        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13906    };
13907    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13908        (Some(offer), Some(selected)) => negotiated_auth_proof(
13909            auth_token,
13910            AuthDirection::Client,
13911            hello.role,
13912            &hello.client_nonce,
13913            &server_nonce,
13914            offer,
13915            selected,
13916        ),
13917        (None, None) => auth_proof(
13918            auth_token,
13919            AuthDirection::Client,
13920            hello.role,
13921            &hello.client_nonce,
13922            &server_nonce,
13923        ),
13924        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13925    }
13926    .map_err(NodeServerError::Authentication)?;
13927    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13928        return Err(NodeServerError::Handshake("access denied".to_owned()));
13929    }
13930    let selected_capabilities = compatibility
13931        .as_ref()
13932        .map(|selected| selected.capabilities.clone())
13933        .unwrap_or_default();
13934    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13935        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13936    });
13937    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13938        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13939    });
13940    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13941        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13942    });
13943    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13944        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13945    });
13946    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13947        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13948    });
13949    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13950        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13951    }) && selected_capabilities.iter().any(|capability| {
13952        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13953    });
13954    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13955        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13956    });
13957    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13958        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13959    });
13960    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13961        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13962    });
13963    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13964        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13965    });
13966    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13967        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13968    });
13969    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13970        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13971    });
13972    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13973        .try_acquire_owned()
13974        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13975    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13976    let _release_guard = ControllerReleaseGuard {
13977        shared: Arc::clone(&shared),
13978        connection_id,
13979    };
13980    drop(preauth_permit);
13981    let _authenticated_permit = authenticated_permit;
13982    let mut event_rx = shared.event_tx.subscribe();
13983    let mut terminal_event_rx = terminal_event_subscription(
13984        &shared,
13985        include_terminal_frame_events,
13986    );
13987    let mut agent_stream_event_rx = agent_stream_event_subscription(
13988        &shared,
13989        include_agent_stream_events,
13990    );
13991    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13992        &shared,
13993        include_harness_mcp_proxy,
13994    );
13995    write_json_frame(
13996        &mut pipe,
13997        &ServerFrame::Hello(NodeHello {
13998            build_stamp: BUILD_STAMP.to_owned(),
13999            incarnation_id: shared.incarnation_id,
14000            connection_id,
14001            role: hello.role,
14002            event_sequence: shared.current_sequence(),
14003            controller: shared.controller_state(),
14004            snapshot: snapshot_for_wire(
14005                &shared,
14006                include_provider_runtime_status,
14007                include_open_provider_ids,
14008                include_spawn_profiles,
14009                include_managed_worktrees,
14010                include_child_environment_profiles,
14011                include_session_bundles,
14012                include_history_context_packs,
14013                include_agent_progress,
14014                include_session_task_correlation,
14015            ),
14016            compatibility,
14017        }),
14018    )
14019    .await?;
14020    let (mut reader, mut writer) = tokio::io::split(pipe);
14021    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14022    let reader_task = tokio::spawn(async move {
14023        loop {
14024            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14025                &mut reader,
14026                MAX_NODE_CLIENT_FRAME_BYTES,
14027                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14028            )
14029            .await;
14030            let terminal = frame.is_err();
14031            if frame_tx.send(frame).await.is_err() || terminal {
14032                break;
14033            }
14034        }
14035    });
14036    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14037    let mut pending_events = Vec::new();
14038    let mut resync_required = false;
14039    let mut discard_events_through = 0_u64;
14040    loop {
14041        // One tick of the serve loop, whether or not it had anything to do.
14042        // Against `connection_events_sent` this separates "busy" from
14043        // "spinning": a loop turning over thousands of times a second while
14044        // sending nothing is not serving, it is burning a core.
14045        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14046        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14047            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14048        for _ in 0..durable_drain_budget {
14049            match event_rx.try_recv() {
14050                Ok(event) => queue_connection_event(
14051                    &mut pending_events,
14052                    event,
14053                    discard_events_through,
14054                ),
14055                Err(broadcast::error::TryRecvError::Empty) => break,
14056                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14057                    resync_required = true;
14058                    break;
14059                }
14060                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14061            }
14062        }
14063        if let Some(receiver) = terminal_event_rx.as_mut() {
14064            match receiver.try_recv() {
14065                Ok(events) => queue_connection_event_batch(
14066                    &mut pending_events,
14067                    &events,
14068                    discard_events_through,
14069                ),
14070                Err(broadcast::error::TryRecvError::Empty) => {}
14071                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14072                    resync_required = true;
14073                }
14074                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14075            }
14076        }
14077        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14078            // Loops up to the same remaining-room budget the durable
14079            // `event_rx` drain above uses, rather than one `try_recv` per
14080            // tick: each successful receive here is itself only ONE
14081            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14082            // sends `vec![envelope]`, never more), so a single-shot drain
14083            // caps this connection's agent-stream throughput at one chunk
14084            // per outer-loop tick regardless of how many are already queued
14085            // -- exactly the kind of self-inflicted stall this crate's own
14086            // durable-event budget exists to avoid.
14087            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14088                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14089            for _ in 0..agent_stream_drain_budget {
14090                match receiver.try_recv() {
14091                    Ok(events) => queue_connection_event_batch(
14092                        &mut pending_events,
14093                        &events,
14094                        discard_events_through,
14095                    ),
14096                    Err(broadcast::error::TryRecvError::Empty) => break,
14097                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14098                        tracing::warn!(
14099                            connection_id,
14100                            dropped,
14101                            "agent stream event subscription lagged; forcing a resync",
14102                        );
14103                        resync_required = true;
14104                        break;
14105                    }
14106                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14107                }
14108            }
14109        }
14110        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14111            // Mirrors the agent-stream drain immediately above: each
14112            // successful receive is one single-envelope batch
14113            // (`publish_transient` always sends `vec![envelope]`), so this
14114            // loops up to the same remaining-room budget rather than
14115            // stopping after one `try_recv`.
14116            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14117                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14118            for _ in 0..harness_mcp_proxy_drain_budget {
14119                match receiver.try_recv() {
14120                    Ok(events) => queue_connection_event_batch(
14121                        &mut pending_events,
14122                        &events,
14123                        discard_events_through,
14124                    ),
14125                    Err(broadcast::error::TryRecvError::Empty) => break,
14126                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14127                        tracing::warn!(
14128                            connection_id,
14129                            dropped,
14130                            "harness MCP proxy event subscription lagged; forcing a resync",
14131                        );
14132                        resync_required = true;
14133                        break;
14134                    }
14135                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14136                }
14137            }
14138        }
14139        if resync_required {
14140            let event = resync_required_event(&shared);
14141            apply_connection_resync_watermark(
14142                &mut pending_events,
14143                &mut discard_events_through,
14144                event.sequence,
14145            );
14146            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14147            resync_required = false;
14148            continue;
14149        }
14150        if !pending_events.is_empty() {
14151            pending_events.sort_unstable_by_key(|event| event.sequence);
14152            let burst_len = connection_event_burst_len(pending_events.len());
14153            shared
14154                .connection_events_sent
14155                .fetch_add(burst_len as u64, Ordering::Relaxed);
14156            for event in pending_events.drain(..burst_len) {
14157                let event = if include_managed_worktrees {
14158                    Some(event)
14159                } else {
14160                    project_event_without_managed_worktrees(event)
14161                };
14162                let event = event.and_then(|event| if include_open_provider_ids {
14163                    Some(event)
14164                } else {
14165                    project_event_legacy_provider_ids(&shared, event)
14166                });
14167                let event = event.map(|event| if include_child_environment_profiles {
14168                    event
14169                } else {
14170                    project_event_without_child_environment_profile(event)
14171                });
14172                let event = event.map(|event| if include_session_bundles {
14173                    event
14174                } else {
14175                    project_event_without_session_bundle(event)
14176                });
14177                let event = event.map(|event| {
14178                    project_event_history_for_wire(event, include_history_context_packs)
14179                });
14180                let event = event.map(|event| if include_history_context_packs {
14181                    event
14182                } else {
14183                    project_event_without_context_pack(event)
14184                });
14185                let event = event.map(|event| if include_session_task_correlation {
14186                    event
14187                } else {
14188                    project_event_without_session_task_binding(event)
14189                });
14190                let exact_harness_controller = hello.role == ClientRole::Operator
14191                    && shared.controller_state().is_some_and(|controller| {
14192                        controller.connection_id == connection_id
14193                    });
14194                let event = event.and_then(|event| {
14195                    if include_harness_mcp_proxy && exact_harness_controller {
14196                        Some(event)
14197                    } else {
14198                        project_event_without_harness_mcp_proxy(event)
14199                    }
14200                });
14201                if let Some(event) = event {
14202                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14203                }
14204            }
14205        }
14206        tokio::select! {
14207            biased;
14208            frame = frame_rx.recv() => {
14209                let frame = match frame {
14210                    Some(frame) => frame,
14211                    None => break,
14212                };
14213                let frame = match frame {
14214                    Ok(frame) => frame,
14215                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14216                    Err(error) => return Err(error.into()),
14217                };
14218                let ClientFrame::Request(request) = frame else {
14219                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14220                };
14221                #[cfg(feature = "fixture")]
14222                let probe_spawn_managed_worktree_v2 = matches!(
14223                    &request.request,
14224                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14225                );
14226                let requires_open_provider_ids =
14227                    request_requires_open_provider_ids(&shared, &request.request);
14228                let requires_child_environment_profile =
14229                    request_requires_child_environment_profile(&shared, &request.request);
14230                let requires_session_bundle =
14231                    request_requires_session_bundle(&shared, &request.request);
14232                let requires_history_context_pack =
14233                    request_requires_history_context_pack(&shared, &request.request);
14234                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14235                    ResponseEnvelope {
14236                        request_id: request.request_id,
14237                        result: Err(failure(
14238                            NodeFailureCode::UnsupportedCapability,
14239                            "open provider IDs were not negotiated",
14240                        )),
14241                    }
14242                } else if requires_child_environment_profile
14243                    && !include_child_environment_profiles
14244                {
14245                    ResponseEnvelope {
14246                        request_id: request.request_id,
14247                        result: Err(failure(
14248                            NodeFailureCode::UnsupportedCapability,
14249                            "child environment profiles were not negotiated",
14250                        )),
14251                    }
14252                } else if requires_session_bundle && !include_session_bundles {
14253                    ResponseEnvelope {
14254                        request_id: request.request_id,
14255                        result: Err(failure(
14256                            NodeFailureCode::UnsupportedCapability,
14257                            "session bundles were not negotiated",
14258                        )),
14259                    }
14260                } else if requires_history_context_pack && !include_history_context_packs {
14261                    ResponseEnvelope {
14262                        request_id: request.request_id,
14263                        result: Err(failure(
14264                            NodeFailureCode::UnsupportedCapability,
14265                            "history context packs were not negotiated",
14266                        )),
14267                    }
14268                } else if request_uses_unnegotiated_capability(
14269                    &request.request,
14270                    &selected_capabilities,
14271                )
14272                {
14273                    ResponseEnvelope {
14274                        request_id: request.request_id,
14275                        result: Err(failure(
14276                            NodeFailureCode::UnsupportedCapability,
14277                            "request capability was not negotiated",
14278                        )),
14279                    }
14280                } else {
14281                    process_request(&shared, connection_id, hello.role, request).await
14282                };
14283                #[cfg(feature = "fixture")]
14284                if probe_spawn_managed_worktree_v2 {
14285                    shared
14286                        .fixture_spawn_managed_worktree_v2_failure_probe
14287                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14288                }
14289                if !include_provider_runtime_status {
14290                    clear_response_provider_runtime_status(&mut reply);
14291                }
14292                if !include_managed_worktrees {
14293                    project_response_without_managed_worktrees(&mut reply);
14294                }
14295                if !include_open_provider_ids {
14296                    project_response_legacy_provider_ids(&shared, &mut reply);
14297                }
14298                if !include_child_environment_profiles {
14299                    project_response_without_child_environment_profile(&mut reply);
14300                }
14301                if !include_session_bundles {
14302                    project_response_without_session_bundle(&mut reply);
14303                }
14304                project_response_history_for_wire(
14305                    &mut reply,
14306                    include_history_context_packs,
14307                );
14308                if !include_history_context_packs {
14309                    project_response_without_context_pack(&mut reply);
14310                }
14311                if !include_agent_progress {
14312                    project_response_without_agent_progress(&mut reply);
14313                }
14314                if !include_session_task_correlation {
14315                    project_response_without_session_task_binding(&mut reply);
14316                }
14317                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14318            }
14319            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14320            event = event_rx.recv() => {
14321                match event {
14322                    Ok(event) => queue_connection_event(
14323                        &mut pending_events,
14324                        event,
14325                        discard_events_through,
14326                    ),
14327                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14328                    Err(broadcast::error::RecvError::Closed) => break,
14329                }
14330            }
14331            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14332                match events {
14333                    Ok(events) => queue_connection_event_batch(
14334                        &mut pending_events,
14335                        &events,
14336                        discard_events_through,
14337                    ),
14338                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14339                    Err(broadcast::error::RecvError::Closed) => break,
14340                }
14341            }
14342            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14343                match events {
14344                    Ok(events) => queue_connection_event_batch(
14345                        &mut pending_events,
14346                        &events,
14347                        discard_events_through,
14348                    ),
14349                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14350                    Err(broadcast::error::RecvError::Closed) => break,
14351                }
14352            }
14353            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14354                match events {
14355                    Ok(events) => queue_connection_event_batch(
14356                        &mut pending_events,
14357                        &events,
14358                        discard_events_through,
14359                    ),
14360                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14361                    Err(broadcast::error::RecvError::Closed) => break,
14362                }
14363            }
14364            _ = shared.shutdown_notify.notified() => break,
14365        }
14366    }
14367    Ok(())
14368}
14369
14370async fn receive_terminal_event_batch(
14371    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14372) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14373    match receiver {
14374        Some(receiver) => receiver.recv().await,
14375        None => std::future::pending().await,
14376    }
14377}
14378
14379fn terminal_event_subscription(
14380    shared: &NodeShared,
14381    enabled: bool,
14382) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14383    enabled.then(|| shared.terminal_event_tx.subscribe())
14384}
14385
14386/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14387/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14388/// this needs a dedicated channel rather than a flag on the terminal one.
14389fn agent_stream_event_subscription(
14390    shared: &NodeShared,
14391    enabled: bool,
14392) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14393    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14394}
14395
14396/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14397/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14398/// own doc for why this needs a dedicated channel rather than a ride on
14399/// the durable `event_tx`.
14400fn harness_mcp_proxy_event_subscription(
14401    shared: &NodeShared,
14402    enabled: bool,
14403) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14404    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14405}
14406
14407fn queue_connection_event(
14408    pending: &mut Vec<NodeEventEnvelope>,
14409    event: NodeEventEnvelope,
14410    discard_through: u64,
14411) {
14412    if event.sequence <= discard_through
14413        && !event.event.requires_harness_mcp_proxy_capability()
14414    {
14415        return;
14416    }
14417    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14418        if let Some(index) = pending.iter().position(|current| {
14419            matches!(
14420                &current.event,
14421                NodeEvent::TerminalFrame {
14422                    address: current_address,
14423                    ..
14424                } if current_address == address
14425            )
14426        }) {
14427            pending.remove(index);
14428        }
14429    }
14430    pending.push(event);
14431}
14432
14433fn project_event_without_harness_mcp_proxy(
14434    envelope: NodeEventEnvelope,
14435) -> Option<NodeEventEnvelope> {
14436    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14437}
14438
14439fn queue_connection_event_batch(
14440    pending: &mut Vec<NodeEventEnvelope>,
14441    events: &[NodeEventEnvelope],
14442    discard_through: u64,
14443) {
14444    for event in events {
14445        queue_connection_event(pending, event.clone(), discard_through);
14446    }
14447}
14448
14449fn apply_connection_resync_watermark(
14450    pending: &mut Vec<NodeEventEnvelope>,
14451    discard_through: &mut u64,
14452    marker_sequence: u64,
14453) {
14454    *discard_through = (*discard_through).max(marker_sequence);
14455    pending.retain(|event| event.sequence > *discard_through);
14456}
14457
14458fn connection_event_burst_len(pending_len: usize) -> usize {
14459    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14460}
14461
14462fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14463    let history = shared
14464        .history
14465        .lock()
14466        .unwrap_or_else(|poisoned| poisoned.into_inner());
14467    let sequence = history.last_sequence;
14468    let oldest_available_sequence = history.replay_floor_sequence;
14469    NodeEventEnvelope {
14470        sequence,
14471        event: NodeEvent::ResyncRequired {
14472            oldest_available_sequence,
14473        },
14474    }
14475}
14476
14477fn node_compatibility_support(
14478    shared: &NodeShared,
14479) -> Result<NodeCompatibilitySupport, NodeServerError> {
14480    let mut support = node_compatibility_support_for_manifest(
14481        &shared.provider_contracts,
14482        &shared.provider_adapter_contracts,
14483    )?;
14484    if shared
14485        .delivery_store
14486        .lock()
14487        .unwrap_or_else(|poisoned| poisoned.into_inner())
14488        .is_some()
14489    {
14490        support.capabilities.push(
14491            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14492                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14493        );
14494    }
14495    if shared.harness_mcp_registry.is_some() {
14496        support.capabilities.push(
14497            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14498                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14499        );
14500    }
14501    Ok(support)
14502}
14503
14504fn node_compatibility_support_for_manifest(
14505    provider_contracts: &[ProviderContractSupport],
14506    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14507) -> Result<NodeCompatibilitySupport, NodeServerError> {
14508    Ok(NodeCompatibilitySupport {
14509        build_stamp: BUILD_STAMP.to_owned(),
14510        capabilities: baseline_capabilities()?,
14511        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14512        path_semantics: platform::path_semantics(),
14513        local_transport: platform::local_transport(),
14514        state_schema: StateSchemaSupport {
14515            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14516                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14517        },
14518        provider_contracts: provider_contracts.to_vec(),
14519        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14520    })
14521}
14522
14523fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14524    let capabilities = [
14525        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14526        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14527        NODE_REPOSITORY_PATH_CAPABILITY,
14528        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14529        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14530        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14531        NODE_GIT_READ_CAPABILITY,
14532        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14533        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14534        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14535        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14536        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14537        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14538        NODE_ACP_CONTROL_CAPABILITY,
14539        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14540        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14541        NODE_WORKTREE_SELECTION_CAPABILITY,
14542        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14543        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14544        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14545        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14546        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14547        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14548        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14549        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14550        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14551        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14552        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14553        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14554    ]
14555        .into_iter()
14556        .map(|capability| {
14557            CapabilityId::new(capability)
14558                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14559        })
14560        .collect::<Result<Vec<_>, _>>()?;
14561    #[cfg(windows)]
14562    let capabilities = {
14563        let mut capabilities = capabilities;
14564        capabilities.push(
14565            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14566                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14567        );
14568        capabilities
14569    };
14570    Ok(capabilities)
14571}
14572
14573fn request_uses_unnegotiated_capability(
14574    request: &NodeRequest,
14575    selected_capabilities: &[CapabilityId],
14576) -> bool {
14577    let selected = |required: &str| {
14578        selected_capabilities
14579            .iter()
14580            .any(|capability| capability.as_str() == required)
14581    };
14582    request
14583        .required_capability()
14584        .is_some_and(|required| !selected(required))
14585        || (request.requires_worktree_selection_capability()
14586            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14587        || (request.requires_spawn_spec_defaults_overrides_capability()
14588            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14589        || (request.requires_spawn_profile_revision_capability()
14590            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14591        || (request.requires_child_environment_profile_capability()
14592            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14593        || (request.requires_session_bundle_materialization_capability()
14594            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14595        || (request.requires_history_context_pack_capability()
14596            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14597}
14598
14599fn snapshot_for_wire(
14600    shared: &NodeShared,
14601    include_provider_runtime_status: bool,
14602    include_open_provider_ids: bool,
14603    include_spawn_profiles: bool,
14604    include_managed_worktrees: bool,
14605    include_child_environment_profiles: bool,
14606    include_session_bundles: bool,
14607    include_history_context_packs: bool,
14608    include_agent_progress: bool,
14609    include_session_task_correlation: bool,
14610) -> NodeSnapshot {
14611    let mut snapshot = shared.snapshot();
14612    if !include_provider_runtime_status {
14613        snapshot.provider_runtime_statuses.clear();
14614    }
14615    if !include_open_provider_ids {
14616        project_snapshot_legacy_provider_ids(&mut snapshot);
14617    }
14618    if !include_managed_worktrees {
14619        snapshot.managed_worktrees.clear();
14620        for workspace in &mut snapshot.workspaces {
14621            workspace.managed_worktree_profiles = None;
14622        }
14623    }
14624    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14625        if !include_spawn_profiles {
14626            inventory.spawn_profiles = None;
14627        }
14628        if !include_session_bundles {
14629            inventory.bundles = None;
14630        }
14631        if inventory.spawn_profiles.is_none()
14632            && inventory.bundles.is_none()
14633            && inventory.network_allowlists.is_none()
14634        {
14635            snapshot.launch_inventory = None;
14636        }
14637    }
14638    if !include_child_environment_profiles {
14639        clear_snapshot_child_environment_profiles(&mut snapshot);
14640    }
14641    if !include_session_bundles {
14642        clear_snapshot_session_bundles(&mut snapshot);
14643    }
14644    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14645    if !include_history_context_packs {
14646        clear_snapshot_context_packs(&mut snapshot);
14647    }
14648    if !include_agent_progress {
14649        snapshot.agent_progress.clear();
14650    }
14651    if !include_session_task_correlation {
14652        clear_snapshot_session_task_bindings(&mut snapshot);
14653    }
14654    snapshot
14655}
14656
14657fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14658    for record in &mut snapshot.session_records {
14659        record.task_binding = None;
14660    }
14661}
14662
14663fn project_event_without_session_task_binding(
14664    mut envelope: NodeEventEnvelope,
14665) -> NodeEventEnvelope {
14666    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14667        record.task_binding = None;
14668    }
14669    envelope
14670}
14671
14672fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14673    let Ok(response) = reply.result.as_mut() else { return };
14674    match response {
14675        NodeResponse::Snapshot { snapshot, .. } => {
14676            clear_snapshot_session_task_bindings(snapshot);
14677        }
14678        NodeResponse::Resync { snapshot, events, .. } => {
14679            clear_snapshot_session_task_bindings(snapshot);
14680            for event in events {
14681                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14682                    record.task_binding = None;
14683                }
14684            }
14685        }
14686        NodeResponse::SessionRecordUpdated { record }
14687        | NodeResponse::ProviderSessionIndexed { record }
14688        | NodeResponse::NativeSessionIndexed { record, .. }
14689        | NodeResponse::SessionRecordResumed { record, .. } => {
14690            record.task_binding = None;
14691        }
14692        _ => {}
14693    }
14694}
14695
14696fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14697    let Ok(response) = reply.result.as_mut() else { return };
14698    match response {
14699        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14700            snapshot.agent_progress.clear();
14701        }
14702        _ => {}
14703    }
14704}
14705
14706fn project_event_without_managed_worktrees(
14707    mut envelope: NodeEventEnvelope,
14708) -> Option<NodeEventEnvelope> {
14709    if matches!(
14710        envelope.event,
14711        NodeEvent::ManagedWorktreeUpserted { .. }
14712            | NodeEvent::ManagedWorktreeRemoved { .. }
14713    ) {
14714        return None;
14715    }
14716    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14717        workspace.managed_worktree_profiles = None;
14718    }
14719    Some(envelope)
14720}
14721
14722fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14723    if matches!(
14724        reply.result,
14725        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14726            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14727    ) {
14728        reply.result = Err(failure(
14729            NodeFailureCode::UnsupportedCapability,
14730            "managed worktree capability was not negotiated",
14731        ));
14732        return;
14733    }
14734    let Ok(response) = reply.result.as_mut() else { return };
14735    match response {
14736        NodeResponse::Snapshot { snapshot, .. } => {
14737            snapshot.managed_worktrees.clear();
14738            for workspace in &mut snapshot.workspaces {
14739                workspace.managed_worktree_profiles = None;
14740            }
14741        }
14742        NodeResponse::Resync { snapshot, events, .. } => {
14743            snapshot.managed_worktrees.clear();
14744            for workspace in &mut snapshot.workspaces {
14745                workspace.managed_worktree_profiles = None;
14746            }
14747            *events = std::mem::take(events)
14748                .into_iter()
14749                .filter_map(project_event_without_managed_worktrees)
14750                .collect();
14751        }
14752        NodeResponse::WorkspaceRegistered { workspace }
14753        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14754        | NodeResponse::WorktreeCreated { workspace, .. } => {
14755            workspace.managed_worktree_profiles = None;
14756        }
14757        NodeResponse::WorkspaceInspected { inspection } => {
14758            inspection.git.managed_worktree = None;
14759        }
14760        _ => {}
14761    }
14762}
14763
14764fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14765    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14766        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14767    });
14768    if includes_open_provider_ids {
14769        return;
14770    }
14771    compatibility
14772        .provider_contracts
14773        .retain(|contract| provider_id_is_legacy(&contract.provider));
14774    compatibility
14775        .provider_adapter_contracts
14776        .retain(|contract| provider_id_is_legacy(&contract.provider));
14777}
14778
14779fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14780    if let Some(spec) = match request {
14781        NodeRequest::SpawnSpec { spec }
14782        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14783        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14784        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14785        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14786        _ => None,
14787    } {
14788        return shared
14789            .resolve_spawn_spec(spec)
14790            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14791    }
14792    request_requires_open_provider_ids_with(
14793        request,
14794        |session| shared.validate_address(session).ok(),
14795        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14796    )
14797}
14798
14799fn request_requires_child_environment_profile(
14800    shared: &NodeShared,
14801    request: &NodeRequest,
14802) -> bool {
14803    if let Some(spec) = match request {
14804        NodeRequest::SpawnSpec { spec }
14805        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14806        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14807        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14808        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14809        _ => None,
14810    } {
14811        return shared
14812            .resolve_spawn_spec(spec)
14813            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14814    }
14815    match request {
14816        NodeRequest::Resume { session, .. }
14817        | NodeRequest::Prompt { session, .. }
14818        | NodeRequest::Paste { session, .. }
14819        | NodeRequest::Input { session, .. }
14820        | NodeRequest::TerminalBytes { session, .. }
14821        | NodeRequest::TerminalControl { session, .. }
14822        | NodeRequest::Resize { session, .. }
14823        | NodeRequest::Interrupt { session }
14824        | NodeRequest::Stop { session, .. }
14825        | NodeRequest::Remove { session }
14826        | NodeRequest::ResolveInteraction { session, .. }
14827        | NodeRequest::SetSessionMode { session, .. }
14828        | NodeRequest::SetSessionConfigOption { session, .. }
14829        | NodeRequest::SetSessionModel { session, .. }
14830        | NodeRequest::DiscoverHistory { session, .. }
14831        | NodeRequest::LoadHistory { session, .. }
14832        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14833        | NodeRequest::ExportContextPack { session } => shared
14834            .session_bindings
14835            .lock()
14836            .unwrap_or_else(|poisoned| poisoned.into_inner())
14837            .get(&session.session.instance_id)
14838            .filter(|binding| {
14839                binding.workspace_id == session.workspace_id
14840                    && binding.generation == session.session.generation
14841            })
14842            .map_or(true, |binding| binding.environment_profile.is_some()),
14843        NodeRequest::RenameSessionRecord { record_id, .. }
14844        | NodeRequest::SetSessionTask { record_id, .. }
14845        | NodeRequest::ResumeSessionRecord { record_id, .. }
14846        | NodeRequest::ForgetSessionRecord { record_id } => shared
14847            .record(record_id)
14848            .map_or(true, |record| record.environment_profile.is_some()),
14849        NodeRequest::ArmHarnessMcpReservation { .. }
14850        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14851        | NodeRequest::ActivateHarnessMcpReservation { .. }
14852        | NodeRequest::AbortHarnessMcpReservation { .. }
14853        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14854        | NodeRequest::RejectHarnessMcpCall { .. }
14855        | NodeRequest::Snapshot
14856        | NodeRequest::Resync { .. }
14857        | NodeRequest::BeginDeliveryStage { .. }
14858        | NodeRequest::PutDeliveryBlobChunk { .. }
14859        | NodeRequest::CommitDeliveryStage { .. }
14860        | NodeRequest::AbortDeliveryStage { .. }
14861        | NodeRequest::BrowseHostDirectories { .. }
14862        | NodeRequest::InspectWorkspace { .. }
14863        | NodeRequest::ReadWorkspaceFile { .. }
14864        | NodeRequest::WriteWorkspaceFile { .. }
14865        | NodeRequest::CreateWorkspaceFile { .. }
14866        | NodeRequest::CreateWorkspaceDirectory { .. }
14867        | NodeRequest::ReadGitHistory { .. }
14868        | NodeRequest::ReadGitDiff { .. }
14869        | NodeRequest::AcquireController { .. }
14870        | NodeRequest::ReleaseController
14871        | NodeRequest::Spawn { .. }
14872        | NodeRequest::SpawnWithOptions { .. }
14873        | NodeRequest::SpawnSpec { .. }
14874        | NodeRequest::RegisterWorkspace { .. }
14875        | NodeRequest::CreateStandaloneWorkspace { .. }
14876        | NodeRequest::UnregisterWorkspace { .. }
14877        | NodeRequest::CreateWorktree { .. }
14878        | NodeRequest::RemoveWorktree { .. }
14879        | NodeRequest::SpawnManagedWorktree { .. }
14880        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14881        | NodeRequest::CleanupManagedWorktree { .. }
14882        | NodeRequest::IndexProviderSession { .. }
14883        | NodeRequest::IndexNativeSession { .. }
14884        | NodeRequest::CatalogNativeSessions { .. }
14885        | NodeRequest::PageNativeSessions { .. }
14886        | NodeRequest::PreviewNativeSession { .. }
14887        | NodeRequest::PreviewSessionRecord { .. }
14888        | NodeRequest::ForgetContextPack { .. }
14889        | NodeRequest::ResolveDurableContextPack { .. }
14890        | NodeRequest::ReadContextPack { .. }
14891        | NodeRequest::Shutdown => false,
14892    }
14893}
14894
14895fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14896    if let Some(spec) = match request {
14897        NodeRequest::SpawnSpec { spec }
14898        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14899        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14900        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14901        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14902        _ => None,
14903    } {
14904        return shared
14905            .resolve_spawn_spec(spec)
14906            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14907    }
14908    match request {
14909        NodeRequest::Resume { session, .. }
14910        | NodeRequest::Prompt { session, .. }
14911        | NodeRequest::Paste { session, .. }
14912        | NodeRequest::Input { session, .. }
14913        | NodeRequest::TerminalBytes { session, .. }
14914        | NodeRequest::TerminalControl { session, .. }
14915        | NodeRequest::Resize { session, .. }
14916        | NodeRequest::Interrupt { session }
14917        | NodeRequest::Stop { session, .. }
14918        | NodeRequest::Remove { session }
14919        | NodeRequest::ResolveInteraction { session, .. }
14920        | NodeRequest::SetSessionMode { session, .. }
14921        | NodeRequest::SetSessionConfigOption { session, .. }
14922        | NodeRequest::SetSessionModel { session, .. }
14923        | NodeRequest::DiscoverHistory { session, .. }
14924        | NodeRequest::LoadHistory { session, .. }
14925        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14926        | NodeRequest::ExportContextPack { session } => shared
14927            .session_bindings
14928            .lock()
14929            .unwrap_or_else(|poisoned| poisoned.into_inner())
14930            .get(&session.session.instance_id)
14931            .filter(|binding| {
14932                binding.workspace_id == session.workspace_id
14933                    && binding.generation == session.session.generation
14934            })
14935            .map_or(true, |binding| binding.bundle.is_some()),
14936        NodeRequest::RenameSessionRecord { record_id, .. }
14937        | NodeRequest::SetSessionTask { record_id, .. }
14938        | NodeRequest::ResumeSessionRecord { record_id, .. }
14939        | NodeRequest::ForgetSessionRecord { record_id } => shared
14940            .record(record_id)
14941            .map_or(true, |record| record.bundle.is_some()),
14942        NodeRequest::ArmHarnessMcpReservation { .. }
14943        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14944        | NodeRequest::ActivateHarnessMcpReservation { .. }
14945        | NodeRequest::AbortHarnessMcpReservation { .. }
14946        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14947        | NodeRequest::RejectHarnessMcpCall { .. }
14948        | NodeRequest::Snapshot
14949        | NodeRequest::Resync { .. }
14950        | NodeRequest::BeginDeliveryStage { .. }
14951        | NodeRequest::PutDeliveryBlobChunk { .. }
14952        | NodeRequest::CommitDeliveryStage { .. }
14953        | NodeRequest::AbortDeliveryStage { .. }
14954        | NodeRequest::BrowseHostDirectories { .. }
14955        | NodeRequest::InspectWorkspace { .. }
14956        | NodeRequest::ReadWorkspaceFile { .. }
14957        | NodeRequest::WriteWorkspaceFile { .. }
14958        | NodeRequest::CreateWorkspaceFile { .. }
14959        | NodeRequest::CreateWorkspaceDirectory { .. }
14960        | NodeRequest::ReadGitHistory { .. }
14961        | NodeRequest::ReadGitDiff { .. }
14962        | NodeRequest::AcquireController { .. }
14963        | NodeRequest::ReleaseController
14964        | NodeRequest::Spawn { .. }
14965        | NodeRequest::SpawnWithOptions { .. }
14966        | NodeRequest::SpawnSpec { .. }
14967        | NodeRequest::RegisterWorkspace { .. }
14968        | NodeRequest::CreateStandaloneWorkspace { .. }
14969        | NodeRequest::UnregisterWorkspace { .. }
14970        | NodeRequest::CreateWorktree { .. }
14971        | NodeRequest::RemoveWorktree { .. }
14972        | NodeRequest::SpawnManagedWorktree { .. }
14973        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14974        | NodeRequest::CleanupManagedWorktree { .. }
14975        | NodeRequest::IndexProviderSession { .. }
14976        | NodeRequest::IndexNativeSession { .. }
14977        | NodeRequest::CatalogNativeSessions { .. }
14978        | NodeRequest::PageNativeSessions { .. }
14979        | NodeRequest::PreviewNativeSession { .. }
14980        | NodeRequest::PreviewSessionRecord { .. }
14981        | NodeRequest::ForgetContextPack { .. }
14982        | NodeRequest::ResolveDurableContextPack { .. }
14983        | NodeRequest::ReadContextPack { .. }
14984        | NodeRequest::Shutdown => false,
14985    }
14986}
14987
14988fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14989    if request.requires_history_context_pack_capability() {
14990        return true;
14991    }
14992    if let Some(spec) = match request {
14993        NodeRequest::SpawnSpec { spec }
14994        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14995        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14996        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14997        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14998        _ => None,
14999    } {
15000        return shared
15001            .resolve_spawn_spec(spec)
15002            .map_or(true, |resolved| resolved.context_id.is_some());
15003    }
15004    match request {
15005        NodeRequest::Resume { session, .. }
15006        | NodeRequest::Prompt { session, .. }
15007        | NodeRequest::Paste { session, .. }
15008        | NodeRequest::Input { session, .. }
15009        | NodeRequest::TerminalBytes { session, .. }
15010        | NodeRequest::TerminalControl { session, .. }
15011        | NodeRequest::Resize { session, .. }
15012        | NodeRequest::Interrupt { session }
15013        | NodeRequest::Stop { session, .. }
15014        | NodeRequest::Remove { session }
15015        | NodeRequest::ResolveInteraction { session, .. }
15016        | NodeRequest::SetSessionMode { session, .. }
15017        | NodeRequest::SetSessionConfigOption { session, .. }
15018        | NodeRequest::SetSessionModel { session, .. } => shared
15019            .session_bindings
15020            .lock()
15021            .unwrap_or_else(|poisoned| poisoned.into_inner())
15022            .get(&session.session.instance_id)
15023            .filter(|binding| {
15024                binding.workspace_id == session.workspace_id
15025                    && binding.generation == session.session.generation
15026            })
15027            .map_or(true, |binding| binding.context.is_some()),
15028        NodeRequest::RenameSessionRecord { record_id, .. }
15029        | NodeRequest::SetSessionTask { record_id, .. }
15030        | NodeRequest::ResumeSessionRecord { record_id, .. }
15031        | NodeRequest::ForgetSessionRecord { record_id } => shared
15032            .record(record_id)
15033            .map_or(true, |record| record.context.is_some()),
15034        NodeRequest::ArmHarnessMcpReservation { .. }
15035        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15036        | NodeRequest::ActivateHarnessMcpReservation { .. }
15037        | NodeRequest::AbortHarnessMcpReservation { .. }
15038        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15039        | NodeRequest::RejectHarnessMcpCall { .. }
15040        | NodeRequest::Snapshot
15041        | NodeRequest::Resync { .. }
15042        | NodeRequest::BeginDeliveryStage { .. }
15043        | NodeRequest::PutDeliveryBlobChunk { .. }
15044        | NodeRequest::CommitDeliveryStage { .. }
15045        | NodeRequest::AbortDeliveryStage { .. }
15046        | NodeRequest::BrowseHostDirectories { .. }
15047        | NodeRequest::InspectWorkspace { .. }
15048        | NodeRequest::ReadWorkspaceFile { .. }
15049        | NodeRequest::WriteWorkspaceFile { .. }
15050        | NodeRequest::CreateWorkspaceFile { .. }
15051        | NodeRequest::CreateWorkspaceDirectory { .. }
15052        | NodeRequest::ReadGitHistory { .. }
15053        | NodeRequest::ReadGitDiff { .. }
15054        | NodeRequest::AcquireController { .. }
15055        | NodeRequest::ReleaseController
15056        | NodeRequest::Spawn { .. }
15057        | NodeRequest::SpawnWithOptions { .. }
15058        | NodeRequest::SpawnSpec { .. }
15059        | NodeRequest::RegisterWorkspace { .. }
15060        | NodeRequest::CreateStandaloneWorkspace { .. }
15061        | NodeRequest::UnregisterWorkspace { .. }
15062        | NodeRequest::CreateWorktree { .. }
15063        | NodeRequest::RemoveWorktree { .. }
15064        | NodeRequest::SpawnManagedWorktree { .. }
15065        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15066        | NodeRequest::CleanupManagedWorktree { .. }
15067        | NodeRequest::IndexProviderSession { .. }
15068        | NodeRequest::IndexNativeSession { .. }
15069        | NodeRequest::CatalogNativeSessions { .. }
15070        | NodeRequest::PageNativeSessions { .. }
15071        | NodeRequest::PreviewNativeSession { .. }
15072        | NodeRequest::PreviewSessionRecord { .. }
15073        | NodeRequest::DiscoverHistory { .. }
15074        | NodeRequest::LoadHistory { .. }
15075        | NodeRequest::ExportContextPackForSessionRecord { .. }
15076        | NodeRequest::ExportContextPack { .. }
15077        | NodeRequest::ForgetContextPack { .. }
15078        | NodeRequest::ResolveDurableContextPack { .. }
15079        | NodeRequest::ReadContextPack { .. }
15080        | NodeRequest::Shutdown => false,
15081    }
15082}
15083
15084fn request_requires_open_provider_ids_with(
15085    request: &NodeRequest,
15086    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15087    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15088) -> bool {
15089    match request {
15090        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15091        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15092        NodeRequest::CatalogNativeSessions { route, .. }
15093        | NodeRequest::PageNativeSessions { route, .. } => {
15094            !provider_id_is_legacy(&route.provider)
15095        }
15096        NodeRequest::PreviewNativeSession { selection, .. }
15097        | NodeRequest::IndexNativeSession { selection, .. } => {
15098            !provider_id_is_legacy(&selection.route.provider)
15099        }
15100        NodeRequest::SpawnSpec { .. }
15101        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15102        NodeRequest::SpawnManagedWorktree { .. }
15103        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15104        NodeRequest::Resume { session, .. }
15105        | NodeRequest::Prompt { session, .. }
15106        | NodeRequest::Paste { session, .. }
15107        | NodeRequest::Input { session, .. }
15108        | NodeRequest::TerminalBytes { session, .. }
15109        | NodeRequest::TerminalControl { session, .. }
15110        | NodeRequest::Resize { session, .. }
15111        | NodeRequest::Interrupt { session }
15112        | NodeRequest::Stop { session, .. }
15113        | NodeRequest::Remove { session }
15114        | NodeRequest::ResolveInteraction { session, .. }
15115        | NodeRequest::SetSessionMode { session, .. }
15116        | NodeRequest::SetSessionConfigOption { session, .. }
15117        | NodeRequest::SetSessionModel { session, .. }
15118        | NodeRequest::DiscoverHistory { session, .. }
15119        | NodeRequest::LoadHistory { session, .. }
15120        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15121        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15122            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15123        NodeRequest::RenameSessionRecord { record_id, .. }
15124        | NodeRequest::SetSessionTask { record_id, .. }
15125        | NodeRequest::ResumeSessionRecord { record_id, .. }
15126        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15127            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15128        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15129            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15130        NodeRequest::ArmHarnessMcpReservation { .. }
15131        | NodeRequest::ActivateHarnessMcpReservation { .. }
15132        | NodeRequest::AbortHarnessMcpReservation { .. }
15133        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15134        | NodeRequest::RejectHarnessMcpCall { .. }
15135        | NodeRequest::Snapshot
15136        | NodeRequest::Resync { .. }
15137        | NodeRequest::BeginDeliveryStage { .. }
15138        | NodeRequest::PutDeliveryBlobChunk { .. }
15139        | NodeRequest::CommitDeliveryStage { .. }
15140        | NodeRequest::AbortDeliveryStage { .. }
15141        | NodeRequest::BrowseHostDirectories { .. }
15142        | NodeRequest::InspectWorkspace { .. }
15143        | NodeRequest::ReadWorkspaceFile { .. }
15144        | NodeRequest::WriteWorkspaceFile { .. }
15145        | NodeRequest::CreateWorkspaceFile { .. }
15146        | NodeRequest::CreateWorkspaceDirectory { .. }
15147        | NodeRequest::ReadGitHistory { .. }
15148        | NodeRequest::ReadGitDiff { .. }
15149        | NodeRequest::AcquireController { .. }
15150        | NodeRequest::ReleaseController
15151        | NodeRequest::RegisterWorkspace { .. }
15152        | NodeRequest::CreateStandaloneWorkspace { .. }
15153        | NodeRequest::UnregisterWorkspace { .. }
15154        | NodeRequest::CreateWorktree { .. }
15155        | NodeRequest::RemoveWorktree { .. }
15156        | NodeRequest::CleanupManagedWorktree { .. }
15157        | NodeRequest::Shutdown => false,
15158        NodeRequest::ForgetContextPack { .. } => true,
15159        // No session/record to cheaply resolve a provider from here either;
15160        // the resolved receipt can carry an open-id provider, so require the
15161        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15162        // is the same digest-keyed lookup one step further into the same
15163        // catalog, so it inherits the same conservative answer.
15164        NodeRequest::ResolveDurableContextPack { .. }
15165        | NodeRequest::ReadContextPack { .. } => true,
15166    }
15167}
15168
15169fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15170    snapshot.enabled_providers.retain(provider_id_is_legacy);
15171    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15172        snapshot
15173            .provider_runtime_statuses
15174            .as_slice()
15175            .iter()
15176            .filter(|status| provider_id_is_legacy(status.provider()))
15177            .cloned(),
15178    )
15179    .expect("a filtered provider runtime projection remains valid");
15180    snapshot
15181        .session_records
15182        .retain(|record| provider_id_is_legacy(&record.provider));
15183    for workspace in &mut snapshot.workspaces {
15184        workspace
15185            .sessions
15186            .retain(|session| provider_id_is_legacy(&session.agent_id));
15187    }
15188}
15189
15190fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15191    for record in &mut snapshot.session_records {
15192        record.environment_profile = None;
15193    }
15194    if let Some(profiles) = snapshot
15195        .launch_inventory
15196        .as_mut()
15197        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15198    {
15199        for profile in profiles {
15200            profile.environment_profile = None;
15201        }
15202    }
15203}
15204
15205fn project_event_without_child_environment_profile(
15206    mut envelope: NodeEventEnvelope,
15207) -> NodeEventEnvelope {
15208    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15209        record.environment_profile = None;
15210    }
15211    envelope
15212}
15213
15214fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15215    let contains_environment_profile = match reply.result.as_ref() {
15216        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15217            receipt.environment_profile.is_some()
15218        }
15219        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15220            receipt.spawn.environment_profile.is_some()
15221        }
15222        _ => false,
15223    };
15224    if contains_environment_profile {
15225        reply.result = Err(failure(
15226            NodeFailureCode::UnsupportedCapability,
15227            "child environment profiles were not negotiated",
15228        ));
15229        return;
15230    }
15231    let Ok(response) = reply.result.as_mut() else {
15232        return;
15233    };
15234    match response {
15235        NodeResponse::Snapshot { snapshot, .. } => {
15236            clear_snapshot_child_environment_profiles(snapshot);
15237        }
15238        NodeResponse::Resync { snapshot, events, .. } => {
15239            clear_snapshot_child_environment_profiles(snapshot);
15240            for event in events {
15241                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15242                    record.environment_profile = None;
15243                }
15244            }
15245        }
15246        NodeResponse::SessionRecordUpdated { record }
15247        | NodeResponse::ProviderSessionIndexed { record }
15248        | NodeResponse::NativeSessionIndexed { record, .. }
15249        | NodeResponse::SessionRecordResumed { record, .. } => {
15250            record.environment_profile = None;
15251        }
15252        NodeResponse::Armed { .. }
15253        | NodeResponse::Spawned { .. }
15254        | NodeResponse::Activated { .. }
15255        | NodeResponse::Aborted { .. }
15256        | NodeResponse::ReplyChunkAccepted { .. }
15257        | NodeResponse::CallRejected { .. }
15258        | NodeResponse::DeliveryStageBegun { .. }
15259        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15260        | NodeResponse::DeliveryCommitted { .. }
15261        | NodeResponse::DeliveryStageAborted { .. }
15262        | NodeResponse::WorkspaceInspected { .. }
15263        | NodeResponse::HostDirectoriesBrowsed { .. }
15264        | NodeResponse::WorkspaceFileRead { .. }
15265        | NodeResponse::WorkspaceFileWritten { .. }
15266        | NodeResponse::WorkspaceFileCreated { .. }
15267        | NodeResponse::WorkspaceDirectoryCreated { .. }
15268        | NodeResponse::GitHistoryRead { .. }
15269        | NodeResponse::GitDiffRead { .. }
15270        | NodeResponse::Controller { .. }
15271        | NodeResponse::SpawnAccepted { .. }
15272        | NodeResponse::SpawnSpecAccepted { .. }
15273        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15274        | NodeResponse::ManagedWorktreeCleanup { .. }
15275        | NodeResponse::NativeSessionsCataloged { .. }
15276        | NodeResponse::NativeSessionsPaged { .. }
15277        | NodeResponse::NativeSessionPreviewed { .. }
15278        | NodeResponse::SessionRecordPreviewed { .. }
15279        | NodeResponse::HistoryDiscovered { .. }
15280        | NodeResponse::HistoryLoaded { .. }
15281        | NodeResponse::ContextPackForSessionRecordExported { .. }
15282        | NodeResponse::ContextPackExported { .. }
15283        | NodeResponse::ContextPackForgotten { .. }
15284        | NodeResponse::DurableContextPackResolved { .. }
15285        | NodeResponse::ContextPackBytesRead { .. }
15286        | NodeResponse::SessionRecordForgotten { .. }
15287        | NodeResponse::WorkspaceRegistered { .. }
15288        | NodeResponse::StandaloneWorkspaceCreated { .. }
15289        | NodeResponse::WorkspaceUnregistered { .. }
15290        | NodeResponse::WorktreeCreated { .. }
15291        | NodeResponse::WorktreeRemoved { .. }
15292        | NodeResponse::Accepted
15293        | NodeResponse::ShuttingDown => {}
15294    }
15295}
15296
15297fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15298    for record in &mut snapshot.session_records {
15299        record.bundle = None;
15300    }
15301}
15302
15303fn project_event_without_session_bundle(
15304    mut envelope: NodeEventEnvelope,
15305) -> NodeEventEnvelope {
15306    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15307        record.bundle = None;
15308    }
15309    envelope
15310}
15311
15312fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15313    let contains_bundle = match reply.result.as_ref() {
15314        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15315        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15316            receipt.spawn.bundle.is_some()
15317        }
15318        _ => false,
15319    };
15320    if contains_bundle {
15321        reply.result = Err(failure(
15322            NodeFailureCode::UnsupportedCapability,
15323            "session bundles were not negotiated",
15324        ));
15325        return;
15326    }
15327    let Ok(response) = reply.result.as_mut() else {
15328        return;
15329    };
15330    match response {
15331        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15332        NodeResponse::Resync { snapshot, events, .. } => {
15333            clear_snapshot_session_bundles(snapshot);
15334            for event in events {
15335                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15336                    record.bundle = None;
15337                }
15338            }
15339        }
15340        NodeResponse::SessionRecordUpdated { record }
15341        | NodeResponse::ProviderSessionIndexed { record }
15342        | NodeResponse::NativeSessionIndexed { record, .. }
15343        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15344        NodeResponse::Armed { .. }
15345        | NodeResponse::Spawned { .. }
15346        | NodeResponse::Activated { .. }
15347        | NodeResponse::Aborted { .. }
15348        | NodeResponse::ReplyChunkAccepted { .. }
15349        | NodeResponse::CallRejected { .. }
15350        | NodeResponse::DeliveryStageBegun { .. }
15351        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15352        | NodeResponse::DeliveryCommitted { .. }
15353        | NodeResponse::DeliveryStageAborted { .. }
15354        | NodeResponse::WorkspaceInspected { .. }
15355        | NodeResponse::HostDirectoriesBrowsed { .. }
15356        | NodeResponse::WorkspaceFileRead { .. }
15357        | NodeResponse::WorkspaceFileWritten { .. }
15358        | NodeResponse::WorkspaceFileCreated { .. }
15359        | NodeResponse::WorkspaceDirectoryCreated { .. }
15360        | NodeResponse::GitHistoryRead { .. }
15361        | NodeResponse::GitDiffRead { .. }
15362        | NodeResponse::Controller { .. }
15363        | NodeResponse::SpawnAccepted { .. }
15364        | NodeResponse::SpawnSpecAccepted { .. }
15365        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15366        | NodeResponse::ManagedWorktreeCleanup { .. }
15367        | NodeResponse::NativeSessionsCataloged { .. }
15368        | NodeResponse::NativeSessionsPaged { .. }
15369        | NodeResponse::NativeSessionPreviewed { .. }
15370        | NodeResponse::SessionRecordPreviewed { .. }
15371        | NodeResponse::HistoryDiscovered { .. }
15372        | NodeResponse::HistoryLoaded { .. }
15373        | NodeResponse::ContextPackForSessionRecordExported { .. }
15374        | NodeResponse::ContextPackExported { .. }
15375        | NodeResponse::ContextPackForgotten { .. }
15376        | NodeResponse::DurableContextPackResolved { .. }
15377        | NodeResponse::ContextPackBytesRead { .. }
15378        | NodeResponse::SessionRecordForgotten { .. }
15379        | NodeResponse::WorkspaceRegistered { .. }
15380        | NodeResponse::StandaloneWorkspaceCreated { .. }
15381        | NodeResponse::WorkspaceUnregistered { .. }
15382        | NodeResponse::WorktreeCreated { .. }
15383        | NodeResponse::WorktreeRemoved { .. }
15384        | NodeResponse::Accepted
15385        | NodeResponse::ShuttingDown => {}
15386    }
15387}
15388
15389fn project_snapshot_history_for_wire(
15390    snapshot: &mut NodeSnapshot,
15391    include_history_metadata: bool,
15392) {
15393    for workspace in &mut snapshot.workspaces {
15394        for session in &mut workspace.sessions {
15395            if include_history_metadata {
15396                session.history.loaded = None;
15397            } else {
15398                session.history = Default::default();
15399            }
15400        }
15401    }
15402}
15403
15404fn project_event_history_for_wire(
15405    mut envelope: NodeEventEnvelope,
15406    include_history_metadata: bool,
15407) -> NodeEventEnvelope {
15408    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15409        for session in &mut workspace.sessions {
15410            if include_history_metadata {
15411                session.history.loaded = None;
15412            } else {
15413                session.history = Default::default();
15414            }
15415        }
15416    }
15417    envelope
15418}
15419
15420fn project_response_history_for_wire(
15421    reply: &mut ResponseEnvelope,
15422    include_history_metadata: bool,
15423) {
15424    let Ok(response) = reply.result.as_mut() else {
15425        return;
15426    };
15427    match response {
15428        NodeResponse::Snapshot { snapshot, .. } => {
15429            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15430        }
15431        NodeResponse::Resync {
15432            snapshot, events, ..
15433        } => {
15434            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15435            for event in events {
15436                *event = project_event_history_for_wire(
15437                    event.clone(),
15438                    include_history_metadata,
15439                );
15440            }
15441        }
15442        NodeResponse::WorkspaceRegistered { workspace }
15443        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15444        | NodeResponse::WorktreeCreated { workspace, .. } => {
15445            for session in &mut workspace.sessions {
15446                if include_history_metadata {
15447                    session.history.loaded = None;
15448                } else {
15449                    session.history = Default::default();
15450                }
15451            }
15452        }
15453        _ => {}
15454    }
15455}
15456
15457fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15458    for record in &mut snapshot.session_records {
15459        record.context_id = None;
15460        record.context = None;
15461        record.exported_context = None;
15462    }
15463}
15464
15465fn project_event_without_context_pack(
15466    mut envelope: NodeEventEnvelope,
15467) -> NodeEventEnvelope {
15468    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15469        record.context_id = None;
15470        record.context = None;
15471        record.exported_context = None;
15472    }
15473    envelope
15474}
15475
15476fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15477    let contains_context = match reply.result.as_ref() {
15478        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15479        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15480            receipt.spawn.context.is_some()
15481        }
15482        Ok(NodeResponse::HistoryDiscovered { .. })
15483        | Ok(NodeResponse::HistoryLoaded { .. })
15484        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15485        | Ok(NodeResponse::ContextPackExported { .. })
15486        | Ok(NodeResponse::ContextPackForgotten { .. })
15487        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15488        _ => false,
15489    };
15490    if contains_context {
15491        reply.result = Err(failure(
15492            NodeFailureCode::UnsupportedCapability,
15493            "history context packs were not negotiated",
15494        ));
15495        return;
15496    }
15497    let Ok(response) = reply.result.as_mut() else {
15498        return;
15499    };
15500    match response {
15501        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15502        NodeResponse::Resync {
15503            snapshot, events, ..
15504        } => {
15505            clear_snapshot_context_packs(snapshot);
15506            for event in events {
15507                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15508                    record.context_id = None;
15509                    record.context = None;
15510                    record.exported_context = None;
15511                }
15512            }
15513        }
15514        NodeResponse::SessionRecordUpdated { record }
15515        | NodeResponse::ProviderSessionIndexed { record }
15516        | NodeResponse::NativeSessionIndexed { record, .. }
15517        | NodeResponse::SessionRecordResumed { record, .. } => {
15518            record.context_id = None;
15519            record.context = None;
15520            record.exported_context = None;
15521        }
15522        _ => {}
15523    }
15524}
15525
15526fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15527    let contains_open_record = match reply.result.as_ref() {
15528        Ok(NodeResponse::SessionRecordUpdated { record })
15529        | Ok(NodeResponse::ProviderSessionIndexed { record })
15530        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15531            !provider_id_is_legacy(&record.provider)
15532        }
15533        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15534            !provider_id_is_legacy(&selection.route.provider)
15535                || !provider_id_is_legacy(&record.provider)
15536        }
15537        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15538            !provider_id_is_legacy(&receipt.provider)
15539        }
15540        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15541            !provider_id_is_legacy(&receipt.spawn.provider)
15542        }
15543        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15544        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15545            !provider_id_is_legacy(&route.provider)
15546        }
15547        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15548            !provider_id_is_legacy(&selection.route.provider)
15549        }
15550        _ => false,
15551    };
15552    if contains_open_record {
15553        reply.result = Err(failure(
15554            NodeFailureCode::UnsupportedCapability,
15555            "open provider IDs were not negotiated",
15556        ));
15557        return;
15558    }
15559    let Ok(response) = reply.result.as_mut() else {
15560        return;
15561    };
15562    match response {
15563        NodeResponse::Snapshot { snapshot, .. } => {
15564            project_snapshot_legacy_provider_ids(snapshot);
15565        }
15566        NodeResponse::Resync { snapshot, events, .. } => {
15567            project_snapshot_legacy_provider_ids(snapshot);
15568            *events = std::mem::take(events)
15569                .into_iter()
15570                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15571                .collect();
15572        }
15573        NodeResponse::WorkspaceRegistered { workspace }
15574        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15575        | NodeResponse::WorktreeCreated { workspace, .. } => {
15576            workspace
15577                .sessions
15578                .retain(|session| provider_id_is_legacy(&session.agent_id));
15579        }
15580        NodeResponse::Armed { .. }
15581        | NodeResponse::Spawned { .. }
15582        | NodeResponse::Activated { .. }
15583        | NodeResponse::Aborted { .. }
15584        | NodeResponse::ReplyChunkAccepted { .. }
15585        | NodeResponse::CallRejected { .. }
15586        | NodeResponse::DeliveryStageBegun { .. }
15587        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15588        | NodeResponse::DeliveryCommitted { .. }
15589        | NodeResponse::DeliveryStageAborted { .. }
15590        | NodeResponse::WorkspaceInspected { .. }
15591        | NodeResponse::HostDirectoriesBrowsed { .. }
15592        | NodeResponse::WorkspaceFileRead { .. }
15593        | NodeResponse::WorkspaceFileWritten { .. }
15594        | NodeResponse::WorkspaceFileCreated { .. }
15595        | NodeResponse::WorkspaceDirectoryCreated { .. }
15596        | NodeResponse::GitHistoryRead { .. }
15597        | NodeResponse::GitDiffRead { .. }
15598        | NodeResponse::Controller { .. }
15599        | NodeResponse::SpawnAccepted { .. }
15600        | NodeResponse::SpawnSpecAccepted { .. }
15601        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15602        | NodeResponse::ManagedWorktreeCleanup { .. }
15603        | NodeResponse::NativeSessionsCataloged { .. }
15604        | NodeResponse::NativeSessionsPaged { .. }
15605        | NodeResponse::NativeSessionPreviewed { .. }
15606        | NodeResponse::SessionRecordPreviewed { .. }
15607        | NodeResponse::HistoryDiscovered { .. }
15608        | NodeResponse::HistoryLoaded { .. }
15609        | NodeResponse::ContextPackForSessionRecordExported { .. }
15610        | NodeResponse::ContextPackExported { .. }
15611        | NodeResponse::ContextPackForgotten { .. }
15612        | NodeResponse::DurableContextPackResolved { .. }
15613        | NodeResponse::ContextPackBytesRead { .. }
15614        | NodeResponse::SessionRecordUpdated { .. }
15615        | NodeResponse::ProviderSessionIndexed { .. }
15616        | NodeResponse::NativeSessionIndexed { .. }
15617        | NodeResponse::SessionRecordResumed { .. }
15618        | NodeResponse::SessionRecordForgotten { .. }
15619        | NodeResponse::WorkspaceUnregistered { .. }
15620        | NodeResponse::WorktreeRemoved { .. }
15621        | NodeResponse::Accepted
15622        | NodeResponse::ShuttingDown => {}
15623    }
15624}
15625
15626fn project_event_legacy_provider_ids(
15627    shared: &NodeShared,
15628    mut envelope: NodeEventEnvelope,
15629) -> Option<NodeEventEnvelope> {
15630    let include = match &mut envelope.event {
15631        NodeEvent::HarnessMcpReadCall { .. } => true,
15632        NodeEvent::Control { address, .. }
15633        | NodeEvent::AgentStream { address, .. }
15634        | NodeEvent::TerminalFrame { address, .. } => shared
15635            .validate_address(address)
15636            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15637        NodeEvent::WorkspaceAdded { workspace } => {
15638            workspace
15639                .sessions
15640                .retain(|session| provider_id_is_legacy(&session.agent_id));
15641            true
15642        }
15643        NodeEvent::SessionRecordUpserted { record } => {
15644            provider_id_is_legacy(&record.provider)
15645        }
15646        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15647        NodeEvent::SessionRecordRemoved { .. } => shared
15648            .history
15649            .lock()
15650            .unwrap_or_else(|poisoned| poisoned.into_inner())
15651            .removed_record_providers
15652            .get(&envelope.sequence)
15653            .is_some_and(provider_id_is_legacy),
15654        NodeEvent::ControllerChanged { .. }
15655        | NodeEvent::ManagedWorktreeUpserted { .. }
15656        | NodeEvent::ManagedWorktreeRemoved { .. }
15657        | NodeEvent::WorkspaceRemoved { .. }
15658        | NodeEvent::ResyncRequired { .. } => true,
15659    };
15660    include.then_some(envelope)
15661}
15662
15663fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15664    let Ok(response) = reply.result.as_mut() else {
15665        return;
15666    };
15667    match response {
15668        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15669            snapshot.provider_runtime_statuses.clear();
15670        }
15671        NodeResponse::Armed { .. }
15672        | NodeResponse::Spawned { .. }
15673        | NodeResponse::Activated { .. }
15674        | NodeResponse::Aborted { .. }
15675        | NodeResponse::ReplyChunkAccepted { .. }
15676        | NodeResponse::CallRejected { .. }
15677        | NodeResponse::DeliveryStageBegun { .. }
15678        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15679        | NodeResponse::DeliveryCommitted { .. }
15680        | NodeResponse::DeliveryStageAborted { .. }
15681        | NodeResponse::WorkspaceInspected { .. }
15682        | NodeResponse::HostDirectoriesBrowsed { .. }
15683        | NodeResponse::WorkspaceFileRead { .. }
15684        | NodeResponse::WorkspaceFileWritten { .. }
15685        | NodeResponse::WorkspaceFileCreated { .. }
15686        | NodeResponse::WorkspaceDirectoryCreated { .. }
15687        | NodeResponse::GitHistoryRead { .. }
15688        | NodeResponse::GitDiffRead { .. }
15689        | NodeResponse::Controller { .. }
15690        | NodeResponse::SpawnAccepted { .. }
15691        | NodeResponse::SpawnSpecAccepted { .. }
15692        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15693        | NodeResponse::ManagedWorktreeCleanup { .. }
15694        | NodeResponse::NativeSessionsCataloged { .. }
15695        | NodeResponse::NativeSessionsPaged { .. }
15696        | NodeResponse::NativeSessionPreviewed { .. }
15697        | NodeResponse::SessionRecordPreviewed { .. }
15698        | NodeResponse::HistoryDiscovered { .. }
15699        | NodeResponse::HistoryLoaded { .. }
15700        | NodeResponse::ContextPackForSessionRecordExported { .. }
15701        | NodeResponse::ContextPackExported { .. }
15702        | NodeResponse::ContextPackForgotten { .. }
15703        | NodeResponse::DurableContextPackResolved { .. }
15704        | NodeResponse::ContextPackBytesRead { .. }
15705        | NodeResponse::SessionRecordUpdated { .. }
15706        | NodeResponse::ProviderSessionIndexed { .. }
15707        | NodeResponse::NativeSessionIndexed { .. }
15708        | NodeResponse::SessionRecordResumed { .. }
15709        | NodeResponse::SessionRecordForgotten { .. }
15710        | NodeResponse::WorkspaceRegistered { .. }
15711        | NodeResponse::StandaloneWorkspaceCreated { .. }
15712        | NodeResponse::WorkspaceUnregistered { .. }
15713        | NodeResponse::WorktreeCreated { .. }
15714        | NodeResponse::WorktreeRemoved { .. }
15715        | NodeResponse::Accepted
15716        | NodeResponse::ShuttingDown => {}
15717    }
15718}
15719
15720struct ControllerReleaseGuard {
15721    shared: Arc<NodeShared>,
15722    connection_id: u64,
15723}
15724
15725impl Drop for ControllerReleaseGuard {
15726    fn drop(&mut self) {
15727        self.shared.release_controller(self.connection_id);
15728    }
15729}
15730
15731struct AbortTaskOnDrop(AbortHandle);
15732
15733impl Drop for AbortTaskOnDrop {
15734    fn drop(&mut self) {
15735        self.0.abort();
15736    }
15737}
15738
15739/// Reads an unsigned env-config override, following the pattern of every
15740/// other bounded knob in this module: missing/unparsable/zero falls back to
15741/// `default`, and the resolved value is always clamped to `max` so a bad
15742/// override cannot reopen a bound this budget exists to enforce.
15743fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15744    std::env::var(name)
15745        .ok()
15746        .and_then(|value| value.parse::<u64>().ok())
15747        .filter(|value| *value > 0)
15748        .unwrap_or(default)
15749        .min(max)
15750}
15751
15752fn workspace_inspection_time_budget_ms() -> u64 {
15753    env_config_bounded(
15754        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15755        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15756        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15757    )
15758}
15759
15760fn workspace_inspection_entry_cap() -> usize {
15761    env_config_bounded(
15762        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15763        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15764        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15765    ) as usize
15766}
15767
15768/// Tracks the shared walk-side budget across the whole recursive walk: a
15769/// wall-clock deadline (shared with the git phase that runs after the walk)
15770/// and a cap on directory entries visited, whether or not they were pushed
15771/// to the response. Cheap to check per entry — one `Instant::now()` and one
15772/// integer compare.
15773struct WorkspaceWalkBudget {
15774    deadline: Instant,
15775    entry_cap: usize,
15776    entries_visited: u64,
15777    time_budget_exceeded: bool,
15778    entry_cap_exceeded: bool,
15779}
15780
15781impl WorkspaceWalkBudget {
15782    fn new(deadline: Instant, entry_cap: usize) -> Self {
15783        Self {
15784            deadline,
15785            entry_cap,
15786            entries_visited: 0,
15787            time_budget_exceeded: false,
15788            entry_cap_exceeded: false,
15789        }
15790    }
15791
15792    fn is_exhausted(&self) -> bool {
15793        self.time_budget_exceeded || self.entry_cap_exceeded
15794    }
15795
15796    /// Records one visited directory entry (pushed to the response or not)
15797    /// and reports whether the walk must stop now.
15798    fn record_visit(&mut self) -> bool {
15799        self.entries_visited += 1;
15800        if self.entries_visited as usize >= self.entry_cap {
15801            self.entry_cap_exceeded = true;
15802        }
15803        if Instant::now() >= self.deadline {
15804            self.time_budget_exceeded = true;
15805        }
15806        self.is_exhausted()
15807    }
15808}
15809
15810fn collect_workspace_entries(
15811    root: &Path,
15812    deadline: Instant,
15813    entry_cap: usize,
15814) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15815    let mut entries = Vec::new();
15816    let mut truncated = false;
15817    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15818    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15819    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15820    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15821    // require strict global byte order over relative paths.
15822    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15823    (entries, truncated, budget)
15824}
15825
15826/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15827/// `inspect_workspace` response — `None` once the walk and git phase both
15828/// completed inside their shared budget.
15829fn workspace_inspection_truncation(
15830    walk_budget: &WorkspaceWalkBudget,
15831    git_time_budget_exceeded: bool,
15832    elapsed: Duration,
15833) -> Option<WorkspaceInspectionTruncationV1> {
15834    if !walk_budget.time_budget_exceeded
15835        && !walk_budget.entry_cap_exceeded
15836        && !git_time_budget_exceeded
15837    {
15838        return None;
15839    }
15840    Some(WorkspaceInspectionTruncationV1 {
15841        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15842        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15843        git_time_budget_exceeded,
15844        entries_visited: walk_budget.entries_visited,
15845        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15846    })
15847}
15848
15849fn walk_workspace_directory(
15850    directory: &Path,
15851    relative_directory: Option<&RepositoryPath>,
15852    depth: usize,
15853    entries: &mut Vec<WorkspaceEntry>,
15854    truncated: &mut bool,
15855    budget: &mut WorkspaceWalkBudget,
15856) {
15857    if budget.is_exhausted() {
15858        *truncated = true;
15859        return;
15860    }
15861    let read_dir = match std::fs::read_dir(directory) {
15862        Ok(read_dir) => read_dir,
15863        Err(_) => {
15864            *truncated = true;
15865            return;
15866        }
15867    };
15868    let mut children = Vec::new();
15869    for child in read_dir {
15870        match child {
15871            Ok(child) => children.push(child),
15872            Err(_) => *truncated = true,
15873        }
15874    }
15875    children.sort_by_key(|child| child.file_name());
15876    for child in children {
15877        if budget.record_visit() {
15878            *truncated = true;
15879            return;
15880        }
15881        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15882            *truncated = true;
15883            return;
15884        }
15885        let file_type = match child.file_type() {
15886            Ok(file_type) => file_type,
15887            Err(_) => {
15888                *truncated = true;
15889                continue;
15890            }
15891        };
15892        if file_type.is_symlink() {
15893            continue;
15894        }
15895        let name = match child.file_name().into_string() {
15896            Ok(name) => name,
15897            Err(_) => {
15898                *truncated = true;
15899                continue;
15900            }
15901        };
15902        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15903            continue;
15904        }
15905        let relative_path = match relative_directory {
15906            None => windows_repository_path(name),
15907            Some(parent) => parent
15908                .as_utf8()
15909                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15910        };
15911        let Some(relative_path) = relative_path else {
15912            *truncated = true;
15913            continue;
15914        };
15915        let kind = if file_type.is_dir() {
15916            WorkspaceEntryKind::Directory
15917        } else if file_type.is_file() {
15918            WorkspaceEntryKind::File
15919        } else {
15920            continue;
15921        };
15922        entries.push(WorkspaceEntry {
15923            relative_path: relative_path.clone(),
15924            kind,
15925        });
15926        if file_type.is_dir() {
15927            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15928                walk_workspace_directory(
15929                    &child.path(),
15930                    Some(&relative_path),
15931                    depth + 1,
15932                    entries,
15933                    truncated,
15934                    budget,
15935                );
15936            } else {
15937                *truncated = true;
15938            }
15939        }
15940    }
15941}
15942
15943fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15944    if value.contains('\\') {
15945        return None;
15946    }
15947    RepositoryPath::utf8(value).ok()
15948}
15949
15950fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15951    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15952}
15953
15954/// Excludes a directory from the workspace tree walk. The three literal
15955/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15956/// beneath them catches every build-output directory regardless of name
15957/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15958/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15959/// directory without maintaining a name heuristic list). The name check
15960/// runs first and returns early so a directory rejected by name never pays
15961/// for the extra `CACHEDIR.TAG` stat.
15962fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15963    if name.eq_ignore_ascii_case(".git")
15964        || name.eq_ignore_ascii_case("target")
15965        || name.eq_ignore_ascii_case("node_modules")
15966    {
15967        return true;
15968    }
15969    path.join("CACHEDIR.TAG").is_file()
15970}
15971
15972fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15973    matches!(
15974        status,
15975        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15976    )
15977}
15978
15979#[derive(Clone, Debug, Eq, PartialEq)]
15980enum ContextPackRepositoryHead {
15981    NotRepository,
15982    Unborn,
15983    Commit(String),
15984}
15985
15986#[derive(Clone, Debug, Eq, PartialEq)]
15987struct ContextPackRepositoryObservation {
15988    head: ContextPackRepositoryHead,
15989    git: GitSnapshot,
15990    files: Vec<ContextPackRepositoryFileSource>,
15991}
15992
15993fn stable_context_pack_repository(
15994    first: ContextPackRepositoryObservation,
15995    second: ContextPackRepositoryObservation,
15996) -> Result<ContextPackRepository, NodeFailure> {
15997    if first != second {
15998        return Err(failure(
15999            NodeFailureCode::ContextPackMaterializationFailed,
16000            "context repository changed during bounded capture",
16001        ));
16002    }
16003    Ok(ContextPackRepository::from_sources(
16004        &second.git,
16005        second.files,
16006    ))
16007}
16008
16009/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16010/// (and only once) the shared walk+git `deadline` has already passed. Every
16011/// remaining phase in `inspect_git_workspace` checks this immediately
16012/// before issuing its own git call, so a budget spent by the walk (or by an
16013/// earlier git call) skips the rest of the chain instead of paying for it
16014/// anyway.
16015fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16016    if Instant::now() < deadline {
16017        return false;
16018    }
16019    append_git_diagnostic(
16020        snapshot,
16021        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16022    );
16023    true
16024}
16025
16026/// Deadline-aware sibling of `run_git_bounded`, used only by
16027/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16028/// whatever remains of the shared `deadline` (never more than
16029/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16030/// of the budget cannot still hand a full per-call timeout to every git
16031/// probe behind it. `inspect_git_workspace` already guards each call site
16032/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16033/// passed yet here.
16034async fn run_git_bounded_with_deadline(
16035    root: &str,
16036    arguments: &[&str],
16037    output_limit: usize,
16038    deadline: Instant,
16039) -> io::Result<GitCommandOutput> {
16040    let remaining = deadline.checked_duration_since(Instant::now());
16041    let timeout_ms = remaining
16042        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16043        .unwrap_or(0)
16044        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16045    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16046}
16047
16048/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16049/// `deadline` is the same absolute instant the preceding directory walk was
16050/// bounded by, so time already spent walking counts against what remains
16051/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16052/// shared budget (not an individual per-call timeout) is what cut the git
16053/// phase short.
16054async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16055    let mut snapshot = GitSnapshot {
16056        is_repository: false,
16057        branch: None,
16058        status: Vec::new(),
16059        recent_commits: Vec::new(),
16060        worktrees: Vec::new(),
16061        managed_worktree: None,
16062        truncated: false,
16063        diagnostic: None,
16064    };
16065    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16066        return (snapshot, true);
16067    }
16068    let repository = match run_git_bounded_with_deadline(
16069        root,
16070        &["rev-parse", "--is-inside-work-tree"],
16071        4 * 1_024,
16072        deadline,
16073    )
16074    .await
16075    {
16076        Ok(output) => output,
16077        Err(error) => {
16078            append_git_diagnostic(
16079                &mut snapshot,
16080                &format!("git inspection unavailable: {error}"),
16081            );
16082            return (snapshot, false);
16083        }
16084    };
16085    snapshot.truncated |= repository.truncated;
16086    if repository.timed_out {
16087        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16088        return (snapshot, false);
16089    }
16090    if !repository.success
16091        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16092    {
16093        let stderr = String::from_utf8_lossy(&repository.stderr);
16094        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16095            append_git_diagnostic(&mut snapshot, stderr.trim());
16096        }
16097        return (snapshot, false);
16098    }
16099    snapshot.is_repository = true;
16100
16101    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16102        return (snapshot, true);
16103    }
16104    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16105        Ok(output) => {
16106            snapshot.truncated |= output.truncated;
16107            if output.timed_out {
16108                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16109            } else if output.success {
16110                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16111                if !branch.is_empty() {
16112                    snapshot.branch = Some(truncate_git_field(
16113                        &branch,
16114                        MAX_REPOSITORY_PATH_BYTES,
16115                        &mut snapshot.truncated,
16116                    ));
16117                }
16118            }
16119        }
16120        Err(error) => append_git_diagnostic(
16121            &mut snapshot,
16122            &format!("git branch query failed: {error}"),
16123        ),
16124    }
16125    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16126        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16127            snapshot.truncated |= output.truncated;
16128            if output.success && !output.timed_out {
16129                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16130                if !head.is_empty() {
16131                    snapshot.branch = Some(format!("detached:{head}"));
16132                }
16133            }
16134        }
16135    }
16136
16137    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16138        return (snapshot, true);
16139    }
16140    match run_git_bounded_with_deadline(
16141        root,
16142        &[
16143            "status",
16144            "--porcelain=v1",
16145            "-z",
16146            "--renames",
16147            "--untracked-files=normal",
16148            "--",
16149            ".",
16150        ],
16151        GIT_OUTPUT_MAX_BYTES,
16152        deadline,
16153    )
16154    .await
16155    {
16156        Ok(output) => {
16157            snapshot.truncated |= output.truncated;
16158            if output.timed_out {
16159                append_git_diagnostic(&mut snapshot, "git status timed out");
16160            } else if output.success {
16161                parse_git_status(&output.stdout, &mut snapshot);
16162            } else {
16163                append_git_diagnostic(
16164                    &mut snapshot,
16165                    String::from_utf8_lossy(&output.stderr).trim(),
16166                );
16167            }
16168        }
16169        Err(error) => append_git_diagnostic(
16170            &mut snapshot,
16171            &format!("git status failed: {error}"),
16172        ),
16173    }
16174
16175    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16176        return (snapshot, true);
16177    }
16178    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16179    match run_git_bounded_with_deadline(
16180        root,
16181        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16182        16 * 1_024,
16183        deadline,
16184    )
16185    .await
16186    {
16187        Ok(output) => {
16188            snapshot.truncated |= output.truncated;
16189            if output.timed_out {
16190                append_git_diagnostic(&mut snapshot, "git log timed out");
16191            } else if output.success {
16192                parse_git_commits(&output.stdout, &mut snapshot);
16193            } else {
16194                let stderr = String::from_utf8_lossy(&output.stderr);
16195                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16196                    append_git_diagnostic(&mut snapshot, stderr.trim());
16197                }
16198            }
16199        }
16200        Err(error) => append_git_diagnostic(
16201            &mut snapshot,
16202            &format!("git log failed: {error}"),
16203        ),
16204    }
16205
16206    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16207        return (snapshot, true);
16208    }
16209    match list_git_worktrees_with_deadline(root, deadline).await {
16210        Ok(worktrees) => {
16211            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16212        }
16213        Err(error) => append_git_diagnostic(
16214            &mut snapshot,
16215            &format!("git worktree list failed: {}", error.message),
16216        ),
16217    }
16218    (snapshot, false)
16219}
16220
16221fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16222    let mut remaining = output;
16223    while !remaining.is_empty() {
16224        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16225            snapshot.truncated = true;
16226            break;
16227        }
16228        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16229            snapshot.truncated = true;
16230            break;
16231        };
16232        let record = &remaining[..record_end];
16233        remaining = &remaining[record_end + 1..];
16234        let has_previous_path = record
16235            .get(..2)
16236            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16237        let previous_path_bytes = if has_previous_path {
16238            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16239                snapshot.truncated = true;
16240                break;
16241            };
16242            let previous = &remaining[..previous_end];
16243            remaining = &remaining[previous_end + 1..];
16244            Some(previous)
16245        } else {
16246            None
16247        };
16248        if record.len() < 4
16249            || record[2] != b' '
16250            || !is_git_status_code(record[0])
16251            || !is_git_status_code(record[1])
16252        {
16253            snapshot.truncated = true;
16254            continue;
16255        }
16256        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16257            snapshot.truncated = true;
16258            continue;
16259        };
16260        let previous_path = match previous_path_bytes {
16261            Some(previous) => {
16262                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16263                    snapshot.truncated = true;
16264                    continue;
16265                };
16266                Some(previous)
16267            }
16268            None => None,
16269        };
16270        snapshot.status.push(GitStatusEntry {
16271            index_status: (record[0] as char).to_string(),
16272            worktree_status: (record[1] as char).to_string(),
16273            path,
16274            previous_path,
16275        });
16276    }
16277}
16278
16279fn is_git_status_code(byte: u8) -> bool {
16280    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16281}
16282
16283fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16284    for line in String::from_utf8_lossy(output).lines() {
16285        let Some((id, summary)) = line.split_once('\u{1f}') else {
16286            continue;
16287        };
16288        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16289            snapshot.truncated = true;
16290            break;
16291        }
16292        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16293        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16294        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16295    }
16296}
16297
16298fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16299    if value.len() <= max_bytes {
16300        return value.to_owned();
16301    }
16302    *truncated = true;
16303    let mut boundary = max_bytes;
16304    while boundary > 0 && !value.is_char_boundary(boundary) {
16305        boundary -= 1;
16306    }
16307    value[..boundary].to_owned()
16308}
16309
16310fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16311    if message.is_empty() {
16312        return;
16313    }
16314    let normalized = message
16315        .chars()
16316        .map(|character| if character.is_control() { ' ' } else { character })
16317        .collect::<String>();
16318    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16319    if !diagnostic.is_empty() {
16320        diagnostic.push_str("; ");
16321    }
16322    diagnostic.push_str(normalized.trim());
16323    snapshot.diagnostic = Some(truncate_git_field(
16324        &diagnostic,
16325        GIT_DIAGNOSTIC_MAX_BYTES,
16326        &mut snapshot.truncated,
16327    ));
16328}
16329
16330async fn run_git_bounded(
16331    root: &str,
16332    arguments: &[&str],
16333    output_limit: usize,
16334) -> io::Result<GitCommandOutput> {
16335    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16336}
16337
16338async fn read_git_history_bounded(
16339    root: &str,
16340    path: Option<&RepositoryPath>,
16341    before: Option<&GitObjectId>,
16342    limit: u16,
16343) -> Result<GitHistoryPage, NodeFailure> {
16344    let path = path
16345        .map(|path| {
16346            path.as_utf8().ok_or_else(|| {
16347                failure(
16348                    NodeFailureCode::InvalidRepositoryPath,
16349                    "git history path must use UTF-8",
16350                )
16351            })
16352        })
16353        .transpose()?
16354        .unwrap_or(".");
16355    let count = (usize::from(limit) + 1).to_string();
16356    let start = before
16357        .map(|revision| format!("{}^", revision.as_str()))
16358        .unwrap_or_else(|| "HEAD".to_owned());
16359    let arguments = [
16360        "--no-pager",
16361        "log",
16362        "--no-decorate",
16363        "--date=iso-strict",
16364        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16365        "-n",
16366        count.as_str(),
16367        start.as_str(),
16368        "--",
16369        path,
16370    ];
16371    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16372        .await
16373        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16374    if output.timed_out {
16375        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16376    }
16377    if !output.success {
16378        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16379    }
16380    let mut commits = parse_git_history(&output.stdout);
16381    let has_more = commits.len() > usize::from(limit) || output.truncated;
16382    commits.truncate(usize::from(limit));
16383    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16384    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16385}
16386
16387fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16388    String::from_utf8_lossy(output)
16389        .split('\u{1e}')
16390        .filter_map(|record| {
16391            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16392            if fields.len() != 9 {
16393                return None;
16394            }
16395            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16396            let parents = if fields[1].is_empty() {
16397                Vec::new()
16398            } else {
16399                fields[1]
16400                    .split(' ')
16401                    .map(|value| GitObjectId::new(value.to_owned()))
16402                    .collect::<Result<Vec<_>, _>>()
16403                    .ok()?
16404            };
16405            Some(GitCommitDetails {
16406                id,
16407                parents,
16408                subject: bounded_git_text(fields[2], 512),
16409                author_name: bounded_git_text(fields[3], 256),
16410                author_email: bounded_git_text(fields[4], 320),
16411                authored_at: bounded_git_text(fields[5], 64),
16412                committer_name: bounded_git_text(fields[6], 256),
16413                committer_email: bounded_git_text(fields[7], 320),
16414                committed_at: bounded_git_text(fields[8], 64),
16415                signature_status: GitSignatureStatus::NoSignature,
16416                signer: None,
16417            })
16418        })
16419        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16420        .collect()
16421}
16422
16423fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16424    let normalized = value
16425        .chars()
16426        .map(|character| if character.is_control() { ' ' } else { character })
16427        .collect::<String>();
16428    if normalized.len() <= max_bytes {
16429        return normalized;
16430    }
16431    let mut boundary = max_bytes;
16432    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16433        boundary -= 1;
16434    }
16435    normalized[..boundary].to_owned()
16436}
16437
16438async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16439    let path = request.path.as_ref().map(|path| {
16440        path.as_utf8()
16441            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16442    }).transpose()?;
16443    let mut arguments = match &request.mode {
16444        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16445        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16446        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16447    };
16448    if let GitDiffMode::Commit { revision } = &request.mode {
16449        arguments.push(revision.as_str());
16450    }
16451    arguments.push("--");
16452    if let Some(path) = path {
16453        arguments.push(path);
16454    }
16455    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16456        .await
16457        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16458    if output.timed_out {
16459        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16460    }
16461    if !output.success {
16462        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16463    }
16464    Ok(GitDiff {
16465        mode: request.mode,
16466        path: request.path,
16467        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16468        truncated: output.truncated,
16469    })
16470}
16471
16472async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16473    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16474    ResponseEnvelope { request_id: envelope.request_id, result }
16475}
16476
16477async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16478    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16479        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16480    }
16481    let read_only = matches!(
16482        &request,
16483        NodeRequest::Snapshot
16484            | NodeRequest::Resync { .. }
16485            | NodeRequest::BrowseHostDirectories { .. }
16486            | NodeRequest::InspectWorkspace { .. }
16487            | NodeRequest::ReadWorkspaceFile { .. }
16488            | NodeRequest::ReadGitHistory { .. }
16489            | NodeRequest::ReadGitDiff { .. }
16490            | NodeRequest::CatalogNativeSessions { .. }
16491            | NodeRequest::PageNativeSessions { .. }
16492            | NodeRequest::PreviewNativeSession { .. }
16493            | NodeRequest::PreviewSessionRecord { .. }
16494    );
16495    let _mutation_guard = if read_only {
16496        None
16497    } else {
16498        let guard = shared.mutation_gate.lock().await;
16499        if shared.shutdown.load(Ordering::Acquire) {
16500            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16501        }
16502        Some(guard)
16503    };
16504    match request {
16505        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16506            event_sequence: shared.current_sequence(),
16507            controller: shared.controller_state(),
16508            snapshot: shared.snapshot(),
16509        }),
16510        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16511        NodeRequest::ArmHarnessMcpReservation {
16512            reservation_id,
16513            activation_digest,
16514            spawn_spec,
16515            launch,
16516            expires_at_unix_ms,
16517        } => {
16518            shared.require_controller(connection_id, role)?;
16519            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16520                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16521            })?;
16522            let expires_at_unix_ms = registry
16523                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16524                .await
16525                .map_err(harness_mcp_failure)?;
16526            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16527        }
16528        NodeRequest::SpawnSpecWithHarnessMcp {
16529            reservation_id,
16530            activation_digest,
16531            spec,
16532            deadline_unix_ms,
16533        } => {
16534            shared.require_controller(connection_id, role)?;
16535            let receipt = shared
16536                .spawn_from_spec_with_harness_mcp(
16537                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16538                )
16539                .await?;
16540            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16541        }
16542        NodeRequest::ActivateHarnessMcpReservation {
16543            reservation_id,
16544            activation_digest,
16545            record_id,
16546            session,
16547        } => {
16548            shared.require_controller(connection_id, role)?;
16549            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16550                current.instance_id == session.session.instance_id
16551                    && current.generation == session.session.generation
16552            }).and_then(|current| current.process_id).ok_or_else(|| {
16553                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16554            })?;
16555            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16556                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16557            })?.activate(
16558                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16559            ).map_err(harness_mcp_failure)?;
16560            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16561        }
16562        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16563            shared.require_controller(connection_id, role)?;
16564            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16565                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16566            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16567            if let Some(instance_id) = instance_id {
16568                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16569            }
16570            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16571        }
16572        NodeRequest::PutHarnessMcpReplyChunk {
16573            reservation_id, activation_digest, record_id, session, call_id,
16574            offset, final_chunk, chunk_hex,
16575        } => {
16576            shared.require_controller(connection_id, role)?;
16577            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16578                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16579            })?.put_reply_chunk(
16580                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16581                offset, final_chunk, &chunk_hex,
16582            ).map_err(harness_mcp_failure)?;
16583            Ok(NodeResponse::ReplyChunkAccepted {
16584                reservation_id, activation_digest, record_id, session, call_id,
16585                next_offset, completed,
16586            })
16587        }
16588        NodeRequest::RejectHarnessMcpCall {
16589            reservation_id, activation_digest, record_id, session, call_id, reason,
16590        } => {
16591            shared.require_controller(connection_id, role)?;
16592            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16593                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16594            })?.reject_call(
16595                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16596            ).map_err(harness_mcp_failure)?;
16597            Ok(NodeResponse::CallRejected {
16598                reservation_id, activation_digest, record_id, session, call_id,
16599            })
16600        }
16601        NodeRequest::BeginDeliveryStage { manifest } => {
16602            shared.require_controller(connection_id, role)?;
16603            shared.begin_delivery_stage(manifest)
16604        }
16605        NodeRequest::PutDeliveryBlobChunk {
16606            stage_id,
16607            blob_digest,
16608            offset,
16609            chunk_hex,
16610        } => {
16611            shared.require_controller(connection_id, role)?;
16612            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16613        }
16614        NodeRequest::CommitDeliveryStage { stage_id } => {
16615            shared.require_controller(connection_id, role)?;
16616            let receipt = shared.commit_delivery_stage(stage_id)?;
16617            Ok(NodeResponse::DeliveryCommitted { receipt })
16618        }
16619        NodeRequest::AbortDeliveryStage { stage_id } => {
16620            shared.require_controller(connection_id, role)?;
16621            shared.abort_delivery_stage(&stage_id)?;
16622            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16623        }
16624        NodeRequest::BrowseHostDirectories { directory, after } => {
16625            let listing = shared.browse_host_directories(directory, after).await?;
16626            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16627        }
16628        NodeRequest::InspectWorkspace { workspace_id } => {
16629            let inspection = shared.inspect_workspace(workspace_id).await?;
16630            Ok(NodeResponse::WorkspaceInspected { inspection })
16631        }
16632        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16633            let file = shared.read_workspace_file(workspace_id, path).await?;
16634            Ok(NodeResponse::WorkspaceFileRead { file })
16635        }
16636        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16637            shared.require_controller(connection_id, role)?;
16638            let file = shared
16639                .write_workspace_file(workspace_id, path, expected_revision, text)
16640                .await?;
16641            Ok(NodeResponse::WorkspaceFileWritten { file })
16642        }
16643        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16644            shared.require_controller(connection_id, role)?;
16645            let file = shared.create_workspace_file(workspace_id, path).await?;
16646            Ok(NodeResponse::WorkspaceFileCreated { file })
16647        }
16648        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16649            shared.require_controller(connection_id, role)?;
16650            let entry = shared
16651                .create_workspace_directory(workspace_id.clone(), path)
16652                .await?;
16653            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16654        }
16655        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16656            if role != ClientRole::Operator {
16657                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16658            }
16659            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16660                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16661            }
16662            let page = shared
16663                .read_git_history(workspace_id.clone(), path, before, limit)
16664                .await?;
16665            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16666        }
16667        NodeRequest::ReadGitDiff { workspace_id, request } => {
16668            if role != ClientRole::Operator {
16669                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16670            }
16671            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16672            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16673        }
16674        NodeRequest::CatalogNativeSessions { route, limit } => {
16675            if role != ClientRole::Operator {
16676                return Err(failure(
16677                    NodeFailureCode::ObserverReadOnly,
16678                    "native session catalog requires an operator connection",
16679                ));
16680            }
16681            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16682                return Err(failure(
16683                    NodeFailureCode::InvalidRequest,
16684                    "native session catalog limit is invalid",
16685                ));
16686            }
16687            let (entries, summary) = shared
16688                .catalog_native_sessions(route.clone(), limit)
16689                .await?;
16690            Ok(NodeResponse::NativeSessionsCataloged {
16691                route,
16692                entries,
16693                summary: Some(summary),
16694            })
16695        }
16696        NodeRequest::PageNativeSessions {
16697            route,
16698            window,
16699            catalog_revision,
16700            recent_cutoff_unix_ms,
16701            after_selection_id,
16702            limit,
16703        } => {
16704            if role != ClientRole::Operator {
16705                return Err(failure(
16706                    NodeFailureCode::ObserverReadOnly,
16707                    "native session catalog paging requires an operator connection",
16708                ));
16709            }
16710            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16711                || after_selection_id
16712                    .as_deref()
16713                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16714            {
16715                return Err(failure(
16716                    NodeFailureCode::InvalidRequest,
16717                    "native session catalog page request is invalid",
16718                ));
16719            }
16720            let page = shared
16721                .page_native_sessions(
16722                    route.clone(),
16723                    window,
16724                    catalog_revision,
16725                    recent_cutoff_unix_ms,
16726                    after_selection_id,
16727                    limit,
16728                )
16729                .await?;
16730            Ok(NodeResponse::NativeSessionsPaged {
16731                route,
16732                page,
16733            })
16734        }
16735        NodeRequest::PreviewNativeSession {
16736            selection,
16737            message_limit,
16738        } => {
16739            if role != ClientRole::Operator {
16740                return Err(failure(
16741                    NodeFailureCode::ObserverReadOnly,
16742                    "native session preview requires an operator connection",
16743                ));
16744            }
16745            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16746                .contains(&message_limit)
16747                || selection.validate().is_err()
16748            {
16749                return Err(failure(
16750                    NodeFailureCode::InvalidRequest,
16751                    "native session preview request is invalid",
16752                ));
16753            }
16754            let preview = shared
16755                .preview_native_session(selection.clone(), message_limit)
16756                .await?;
16757            Ok(NodeResponse::NativeSessionPreviewed {
16758                selection,
16759                preview,
16760            })
16761        }
16762        NodeRequest::IndexNativeSession {
16763            selection,
16764            display_name,
16765        } => {
16766            shared.require_controller(connection_id, role)?;
16767            let record = shared
16768                .index_native_session(selection.clone(), display_name)
16769                .await?;
16770            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16771        }
16772        NodeRequest::PreviewSessionRecord {
16773            record_id,
16774            message_limit,
16775        } => {
16776            if role != ClientRole::Operator {
16777                return Err(failure(
16778                    NodeFailureCode::ObserverReadOnly,
16779                    "native session preview requires an operator connection",
16780                ));
16781            }
16782            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16783                .contains(&message_limit)
16784            {
16785                return Err(failure(
16786                    NodeFailureCode::InvalidRequest,
16787                    "native session preview request is invalid",
16788                ));
16789            }
16790            let record = shared.record(&record_id)?;
16791            let working_directory = shared.workspace_root(&record.workspace_id)?;
16792            if !platform::roots_equal(
16793                &working_directory,
16794                windows_path_text(&record.canonical_root),
16795            ) {
16796                return Err(failure(
16797                    NodeFailureCode::SessionRecordConflict,
16798                    "managed session workspace root changed; refusing to preview another directory",
16799                ));
16800            }
16801            let identity = record.provider_session.clone().ok_or_else(|| {
16802                failure(
16803                    NodeFailureCode::BackendOperationFailed,
16804                    "native session preview is unavailable for this session record",
16805                )
16806            })?;
16807            if identity.key != ProviderSessionKey::SessionId {
16808                return Err(failure(
16809                    NodeFailureCode::BackendOperationFailed,
16810                    "native session preview is unavailable for this session identity",
16811                ));
16812            }
16813            let preview = shared
16814                .preview_native_session_by_session_id(
16815                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16816                )
16817                .await?;
16818            if preview.session_id != identity.id {
16819                return Err(failure(
16820                    NodeFailureCode::SessionRecordConflict,
16821                    "native session preview identity changed while loading",
16822                ));
16823            }
16824            shared.revalidate_session_record_preview(&record, &identity)?;
16825            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16826            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16827                record_id: record_id.clone(),
16828                summary: SessionHistorySummaryV1::from(&preview),
16829            });
16830            Ok(NodeResponse::SessionRecordPreviewed {
16831                record_id,
16832                preview,
16833            })
16834        }
16835        NodeRequest::AcquireController { lease_ms } => {
16836            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16837            Ok(NodeResponse::Controller { controller: Some(controller) })
16838        }
16839        NodeRequest::ReleaseController => {
16840            shared.release_controller(connection_id);
16841            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16842        }
16843        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16844            shared.require_controller(connection_id, role)?;
16845            let root = require_windows_path(root)?;
16846            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16847            let workspace = shared.register_workspace(workspace_id, root).await?;
16848            Ok(NodeResponse::WorkspaceRegistered { workspace })
16849        }
16850        NodeRequest::CreateStandaloneWorkspace {
16851            workspace_id,
16852            root,
16853            initial_branch,
16854        } => {
16855            shared.require_controller(connection_id, role)?;
16856            let root = require_windows_path(root)?;
16857            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16858            let workspace = shared
16859                .create_standalone_workspace(workspace_id, root, initial_branch)
16860                .await?;
16861            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16862        }
16863        NodeRequest::UnregisterWorkspace { workspace_id } => {
16864            shared.require_controller(connection_id, role)?;
16865            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16866            shared.unregister_workspace(&workspace_id)?;
16867            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16868        }
16869        NodeRequest::CreateWorktree {
16870            source_workspace_id,
16871            workspace_id,
16872            target_root,
16873            branch,
16874            base,
16875        } => {
16876            shared.require_controller(connection_id, role)?;
16877            let target_root = require_windows_path(target_root)?;
16878            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16879            let (worktree, workspace) = shared
16880                .create_worktree(
16881                    source_workspace_id,
16882                    workspace_id,
16883                    target_root,
16884                    branch,
16885                    base,
16886                )
16887                .await?;
16888            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16889        }
16890        NodeRequest::RemoveWorktree {
16891            source_workspace_id,
16892            target_root,
16893        } => {
16894            shared.require_controller(connection_id, role)?;
16895            let native_target_root = require_windows_path(target_root.clone())?;
16896            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16897            let workspace_id = shared
16898                .remove_worktree(source_workspace_id, native_target_root)
16899                .await?;
16900            Ok(NodeResponse::WorktreeRemoved {
16901                target_root,
16902                workspace_id,
16903            })
16904        }
16905        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16906            shared.require_controller(connection_id, role)?;
16907            if !terminal_size.is_valid() {
16908                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16909            }
16910            if let Some(prompt) = initial_prompt.as_deref() {
16911                validate_node_text("spawn initial prompt", prompt)?;
16912            }
16913            match shared
16914                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16915                .await
16916            {
16917                Ok(session) => {
16918                    shared.mark_spawn_provisional(session.clone());
16919                    Ok(NodeResponse::SpawnAccepted { session })
16920                }
16921                Err(err) => {
16922                    tracing::warn!(
16923                        %provider,
16924                        %workspace_id,
16925                        ?mode,
16926                        code = ?err.code,
16927                        message = %err.message,
16928                        "session spawn refused"
16929                    );
16930                    Err(err)
16931                }
16932            }
16933        }
16934        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16935            shared.require_controller(connection_id, role)?;
16936            if !terminal_size.is_valid() {
16937                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16938            }
16939            if let Some(prompt) = initial_prompt.as_deref() {
16940                validate_node_text("spawn initial prompt", prompt)?;
16941            }
16942            let plan = backend_paths::plan_launch_options(
16943                provider.as_str(),
16944                mode,
16945                options.model.is_some(),
16946                options.effort.is_some(),
16947            )
16948            .map_err(|r| {
16949                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16950                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16951            })?;
16952            let selection = spawn_selection(&options)?;
16953            let pty_selection = match plan {
16954                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16955                _ => None,
16956            };
16957            let spawned = SPAWN_SESSION_OPTIONS
16958                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16959                .await;
16960            match spawned {
16961                Ok(session) => {
16962                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16963                        shared
16964                            .pending_acp_options
16965                            .lock()
16966                            .unwrap_or_else(|p| p.into_inner())
16967                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16968                    }
16969                    shared.mark_spawn_provisional(session.clone());
16970                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16971                    Ok(NodeResponse::SpawnAccepted { session })
16972                }
16973                Err(err) => {
16974                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16975                    Err(err)
16976                }
16977            }
16978        }
16979        NodeRequest::SpawnSpec { spec } => {
16980            shared.require_controller(connection_id, role)?;
16981            let receipt = shared.spawn_from_spec(spec).await?;
16982            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16983        }
16984        NodeRequest::SpawnManagedWorktree { request } => {
16985            shared.require_controller(connection_id, role)?;
16986            let receipt = shared.spawn_managed_worktree(request).await?;
16987            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16988        }
16989        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16990            shared.require_controller(connection_id, role)?;
16991            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16992            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16993        }
16994        NodeRequest::CleanupManagedWorktree { lease_id } => {
16995            shared.require_controller(connection_id, role)?;
16996            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16997            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16998        }
16999        NodeRequest::DiscoverHistory { session, limit } => {
17000            controlled_session(shared, connection_id, role, &session)?;
17001            let candidates = shared.discover_history(&session, limit).await?;
17002            Ok(NodeResponse::HistoryDiscovered {
17003                session,
17004                candidates,
17005            })
17006        }
17007        NodeRequest::LoadHistory {
17008            session,
17009            candidate_id,
17010        } => {
17011            controlled_session(shared, connection_id, role, &session)?;
17012            let loaded = shared
17013                .load_history(&session, candidate_id)
17014                .await?;
17015            Ok(NodeResponse::HistoryLoaded {
17016                session,
17017                session_id: loaded.session_id,
17018                message_count: loaded.message_count,
17019                completed_turn_count: loaded.completed_turn_count,
17020            })
17021        }
17022        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17023            shared.require_controller(connection_id, role)?;
17024            let context = shared
17025                .export_context_pack_for_session_record(&record_id, &session, false)
17026                .await?;
17027            Ok(NodeResponse::ContextPackForSessionRecordExported {
17028                record_id,
17029                session,
17030                context,
17031            })
17032        }
17033        NodeRequest::ExportContextPack { session } => {
17034            controlled_session(shared, connection_id, role, &session)?;
17035            let context = shared.export_context_pack(&session).await?;
17036            Ok(NodeResponse::ContextPackExported { context })
17037        }
17038        NodeRequest::ForgetContextPack { context_id } => {
17039            shared.require_controller(connection_id, role)?;
17040            shared.forget_context_pack(&context_id)?;
17041            Ok(NodeResponse::ContextPackForgotten { context_id })
17042        }
17043        NodeRequest::ResolveDurableContextPack { context_id } => {
17044            // Read-only, idempotent, no controller lease required: this lets
17045            // a caller preflight "is this durably exported pack still here"
17046            // without holding the Node's mutation controller.
17047            let context = shared
17048                .context_catalog
17049                .read()
17050                .unwrap_or_else(|poisoned| poisoned.into_inner())
17051                .get(&context_id)
17052                .map(|pack| pack.receipt().clone())
17053                .ok_or_else(|| {
17054                    failure(
17055                        NodeFailureCode::UnknownContextPack,
17056                        "context pack is not present",
17057                    )
17058                })?;
17059            Ok(NodeResponse::DurableContextPackResolved { context })
17060        }
17061        NodeRequest::ReadContextPack { digest } => {
17062            // Read-only fetch of the pack's own bytes, keyed by content digest
17063            // rather than the caller's `SpawnContextId`: same no-controller-lease
17064            // rationale as `ResolveDurableContextPack` above, one step further
17065            // down the same catalog.
17066            let (id, byte_len, bytes) = shared
17067                .context_catalog
17068                .read()
17069                .unwrap_or_else(|poisoned| poisoned.into_inner())
17070                .get_by_digest(&digest)
17071                .map(|pack| {
17072                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17073                })
17074                .ok_or_else(|| {
17075                    failure(
17076                        NodeFailureCode::UnknownContextPack,
17077                        &format!("context pack {digest} not on this node"),
17078                    )
17079                })?;
17080            tracing::debug!(
17081                digest = digest.as_str(),
17082                byte_len,
17083                "read context pack bytes",
17084            );
17085            Ok(NodeResponse::ContextPackBytesRead {
17086                pack: ContextPackBytesRead {
17087                    digest,
17088                    id,
17089                    byte_len,
17090                    bytes,
17091                },
17092            })
17093        }
17094        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17095            let provider = controlled_session(shared, connection_id, role, &session)?;
17096            if let Some(prompt) = initial_prompt.as_deref() {
17097                validate_node_text("resume initial prompt", prompt)?;
17098            }
17099            let runtime_requirement = if initial_prompt.is_some() {
17100                ProviderRuntimeRequirement::ResumeWithPrompt
17101            } else {
17102                ProviderRuntimeRequirement::Resume
17103            };
17104            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17105            let runtime_policy = shared
17106                .admit_provider_runtime(&provider, runtime_requirement)
17107                .await?;
17108            let working_directory = shared.bound_workspace_root(&session)?;
17109            let request = ResumeLaunchRequest {
17110                working_directory,
17111                terminal_size,
17112                initial_prompt,
17113            };
17114            request
17115                .validate()
17116                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17117            let command = shared.prepare_command(ControlCommand::Resume {
17118                instance_id: session.session.instance_id,
17119                target: ResumeTarget::CurrentProvider,
17120                runtime_policy,
17121                request,
17122            });
17123            shared.arm_resume(&session, command.id, runtime_policy)?;
17124            let dispatch = shared.dispatch_envelope(command);
17125            if let Err(error) = dispatch {
17126                shared.clear_armed_resume(&session);
17127                return Err(error);
17128            }
17129            Ok(NodeResponse::Accepted)
17130        }
17131        NodeRequest::RenameSessionRecord {
17132            record_id,
17133            display_name,
17134        } => {
17135            shared.require_controller(connection_id, role)?;
17136            let record = shared.rename_session_record(&record_id, display_name)?;
17137            Ok(NodeResponse::SessionRecordUpdated { record })
17138        }
17139        NodeRequest::SetSessionTask {
17140            record_id,
17141            expected_revision,
17142            target,
17143        } => {
17144            shared.require_controller(connection_id, role)?;
17145            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17146            Ok(NodeResponse::SessionRecordUpdated { record })
17147        }
17148        NodeRequest::IndexProviderSession {
17149            workspace_id,
17150            provider,
17151            identity,
17152            display_name,
17153        } => {
17154            shared.require_controller(connection_id, role)?;
17155            let record = shared.index_provider_session(
17156                workspace_id,
17157                provider,
17158                identity,
17159                display_name,
17160            )?;
17161            Ok(NodeResponse::ProviderSessionIndexed { record })
17162        }
17163        NodeRequest::ResumeSessionRecord {
17164            record_id,
17165            terminal_size,
17166            initial_prompt,
17167        } => {
17168            shared.require_controller(connection_id, role)?;
17169            if let Some(prompt) = initial_prompt.as_deref() {
17170                validate_node_text("managed resume initial prompt", prompt)?;
17171            }
17172            let (record, session) = shared
17173                .resume_session_record(
17174                    &record_id,
17175                    terminal_size,
17176                    initial_prompt,
17177                )
17178                .await?;
17179            Ok(NodeResponse::SessionRecordResumed { record, session })
17180        }
17181        NodeRequest::ForgetSessionRecord { record_id } => {
17182            shared.require_controller(connection_id, role)?;
17183            shared.forget_session_record(&record_id).await?;
17184            Ok(NodeResponse::SessionRecordForgotten { record_id })
17185        }
17186        NodeRequest::Prompt { session, text } => {
17187            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17188            validate_node_text("prompt", &text)?;
17189            shared.require_session_runtime_policy(
17190                &session,
17191                ProviderRuntimeRequirement::SemanticPrompt,
17192            )?;
17193            let framing = prompt_framing(&agent_id);
17194            shared
17195                .dispatch_input_bounded(
17196                    &session,
17197                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17198                )
17199                .await?;
17200            Ok(NodeResponse::Accepted)
17201        }
17202        NodeRequest::Paste { session, text } => {
17203            controlled_session(shared, connection_id, role, &session)?;
17204            validate_node_text("paste", &text)?;
17205            shared.require_session_runtime_policy(
17206                &session,
17207                ProviderRuntimeRequirement::SemanticPrompt,
17208            )?;
17209            shared
17210                .dispatch_input_bounded(
17211                    &session,
17212                    InputAction::InsertDraft(PromptPayload {
17213                        text,
17214                        framing: PromptFraming::BracketedPaste,
17215                    }),
17216                )
17217                .await?;
17218            Ok(NodeResponse::Accepted)
17219        }
17220        NodeRequest::Input { session, text } => {
17221            controlled_session(shared, connection_id, role, &session)?;
17222            validate_node_text("terminal input", &text)?;
17223            shared
17224                .dispatch_input_bounded(
17225                    &session,
17226                    InputAction::TerminalText(TerminalText { text }),
17227                )
17228                .await?;
17229            Ok(NodeResponse::Accepted)
17230        }
17231        NodeRequest::TerminalBytes { session, bytes } => {
17232            controlled_session(shared, connection_id, role, &session)?;
17233            shared.sessionize_spawn(session.session.instance_id);
17234            let action = terminal_bytes_action(bytes)?;
17235            shared
17236                .dispatch_input_bounded(&session, action)
17237                .await?;
17238            Ok(NodeResponse::Accepted)
17239        }
17240        NodeRequest::TerminalControl { session, control } => {
17241            controlled_session(shared, connection_id, role, &session)?;
17242            shared
17243                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17244                .await?;
17245            Ok(NodeResponse::Accepted)
17246        }
17247        NodeRequest::Resize { session, size } => {
17248            controlled_session(shared, connection_id, role, &session)?;
17249            shared.sessionize_spawn(session.session.instance_id);
17250            if !size.is_valid() {
17251                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17252            }
17253            shared.dispatch_resize_bounded(&session, size).await?;
17254            Ok(NodeResponse::Accepted)
17255        }
17256        NodeRequest::Interrupt { session } => {
17257            controlled_session(shared, connection_id, role, &session)?;
17258            shared
17259                .dispatch_input_bounded(
17260                    &session,
17261                    InputAction::TerminalControl(TerminalControl::Interrupt),
17262                )
17263                .await?;
17264            Ok(NodeResponse::Accepted)
17265        }
17266        NodeRequest::Stop { session, force } => {
17267            controlled_session(shared, connection_id, role, &session)?;
17268            shared.sessionize_spawn(session.session.instance_id);
17269            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17270            shared.retire_session_grok_leader(session.session.instance_id);
17271            Ok(NodeResponse::Accepted)
17272        }
17273        NodeRequest::Remove { session } => {
17274            controlled_session(shared, connection_id, role, &session)?;
17275            shared.remove_session(&session).await?;
17276            Ok(NodeResponse::Accepted)
17277        }
17278        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17279            shared.require_controller(connection_id, role)?;
17280            let snapshot = shared.require_acp_session(&session)?;
17281            let interaction = snapshot
17282                .provider
17283                .interactions
17284                .iter()
17285                .find(|interaction| {
17286                    interaction_correlation(
17287                        session.session.instance_id,
17288                        session.session.generation,
17289                        interaction.id.0,
17290                    ) == correlation_id
17291                })
17292                .ok_or_else(|| failure(
17293                    NodeFailureCode::InvalidRequest,
17294                    "unknown interaction correlation",
17295                ))?;
17296            if interaction.status != ProviderInteractionStatus::Pending {
17297                return Err(failure(
17298                    NodeFailureCode::BackendOperationFailed,
17299                    "interaction has already been resolved",
17300                ));
17301            }
17302            response
17303                .validate_for(interaction.interaction_kind)
17304                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17305            let interaction_id = interaction.id;
17306            let response_kind = response.kind();
17307            let response_outcome = response.outcome();
17308            shared.dispatch(ControlCommand::ResolveInteraction {
17309                instance_id: session.session.instance_id,
17310                generation: session.session.generation,
17311                interaction_id,
17312                response,
17313            })?;
17314            // The one line that lets an operator compare what THIS node
17315            // recorded against what the provider itself reports doing --
17316            // measured live on a kimi run where our side resolved `true`
17317            // while the agent reported "rejected" and neither layer had a
17318            // line to line up against the other.
17319            tracing::info!(
17320                node_id = %shared.node_id,
17321                session = ?session.session,
17322                correlation_id = %correlation_id,
17323                interaction_id = interaction_id.0,
17324                response_kind = ?response_kind,
17325                outcome = ?response_outcome,
17326                "interaction resolved",
17327            );
17328            Ok(NodeResponse::Accepted)
17329        }
17330        NodeRequest::SetSessionMode { session, mode_id } => {
17331            shared.require_controller(connection_id, role)?;
17332            shared.require_acp_session(&session)?;
17333            shared.dispatch(ControlCommand::SetSessionMode {
17334                instance_id: session.session.instance_id,
17335                mode_id,
17336            })?;
17337            Ok(NodeResponse::Accepted)
17338        }
17339        NodeRequest::SetSessionConfigOption {
17340            session,
17341            option_id,
17342            value_json,
17343        } => {
17344            shared.require_controller(connection_id, role)?;
17345            shared.require_acp_session(&session)?;
17346            shared.dispatch(ControlCommand::SetSessionConfigOption {
17347                instance_id: session.session.instance_id,
17348                option_id,
17349                value_json,
17350            })?;
17351            Ok(NodeResponse::Accepted)
17352        }
17353        NodeRequest::SetSessionModel { session, model_id } => {
17354            shared.require_controller(connection_id, role)?;
17355            shared.require_acp_session(&session)?;
17356            shared.dispatch(ControlCommand::SetSessionModel {
17357                instance_id: session.session.instance_id,
17358                model_id,
17359            })?;
17360            Ok(NodeResponse::Accepted)
17361        }
17362        NodeRequest::Shutdown => {
17363            shared.require_controller(connection_id, role)?;
17364            shared.begin_shutdown_locked().await?;
17365            Ok(NodeResponse::ShuttingDown)
17366        }
17367    }
17368}
17369
17370fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17371    shared.require_controller(connection_id, role)?;
17372    shared.validate_address(session)
17373}
17374
17375/// Selects the sole history candidate matching `provider_session_id` when
17376/// the managed record carries a verified identity, or the sole candidate in
17377/// the discovered set at all when it does not (see
17378/// `export_context_pack_for_session_record_inner`'s identity match). Either
17379/// way, more than one match refuses instead of guessing which one is exact.
17380fn select_history_candidate(
17381    candidates: &[HistoryCandidateSummary],
17382    provider_session_id: Option<&str>,
17383) -> Result<String, NodeFailure> {
17384    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17385        Some(id) => candidate.session_id_hint == id,
17386        None => true,
17387    });
17388    let candidate = matches.next().ok_or_else(|| {
17389        failure(
17390            NodeFailureCode::SessionRecordConflict,
17391            if provider_session_id.is_some() {
17392                "no history candidate matches the managed provider session"
17393            } else {
17394                "no history candidate exists for the managed session"
17395            },
17396        )
17397    })?;
17398    if matches.next().is_some() {
17399        return Err(failure(
17400            NodeFailureCode::SessionRecordConflict,
17401            if provider_session_id.is_some() {
17402                "history identity is ambiguous for the managed provider session"
17403            } else {
17404                "history is ambiguous without a verified provider session identity"
17405            },
17406        ));
17407    }
17408    Ok(candidate.id.clone())
17409}
17410
17411fn resolved_spawn_profile_summary(
17412    profile: &SpawnProfileDefaults,
17413    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17414) -> Option<SpawnProfileSummary> {
17415    let environment_profile = match profile.environment_profile_id.as_ref() {
17416        None => None,
17417        Some(profile_id) => {
17418            let binding = environment_profiles.get(profile_id)?;
17419            if binding.provider != profile.provider
17420                || binding.native_profile_id(profile.mode).is_none()
17421            {
17422                return None;
17423            }
17424            Some(ResolvedEnvironmentProfileReceipt {
17425                profile_id: binding.id.clone(),
17426                profile_revision: binding.revision.clone(),
17427                network_allowlist: None,
17428                browser_profile_id: None,
17429            })
17430        }
17431    };
17432    Some(SpawnProfileSummary {
17433        id: profile.profile_id.clone(),
17434        revision: profile.revision.clone(),
17435        environment_profile,
17436    })
17437}
17438
17439/// True if `current` is an acceptable version of `expected` at export
17440/// revalidation/commit time.
17441///
17442/// When `allow_clean_detachment` is `false` (the on-demand,
17443/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17444/// be byte-identical to `expected` and still actively bound to `session` —
17445/// the original, unchanged invariant.
17446///
17447/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17448/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17449/// export reacts to produces on its own: `state` Live -> Dormant,
17450/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17451/// advancing. Every other field — including `provider_session`, `context`,
17452/// `bundle`, `task_binding` — must still match exactly. A record that changed
17453/// in any other way (rebind, a different active session, an identity
17454/// conflict, capacity eviction) is rejected either way; this widens nothing
17455/// about which sessions are eligible, only how the record's own expected
17456/// detachment is tolerated mid-export.
17457fn session_record_export_target_matches(
17458    expected: &ManagedSessionRecord,
17459    current: &ManagedSessionRecord,
17460    session: &SessionAddress,
17461    allow_clean_detachment: bool,
17462) -> bool {
17463    if current == expected && current.active_session.as_ref() == Some(session) {
17464        return current.state == ManagedSessionState::Live;
17465    }
17466    allow_clean_detachment
17467        && current.state == ManagedSessionState::Dormant
17468        && current.active_session.is_none()
17469        && current.record_id == expected.record_id
17470        && current.display_name == expected.display_name
17471        && current.provider == expected.provider
17472        && current.mode == expected.mode
17473        && current.workspace_id == expected.workspace_id
17474        && current.canonical_root == expected.canonical_root
17475        && current.provider_session == expected.provider_session
17476        && current.environment_profile == expected.environment_profile
17477        && current.bundle == expected.bundle
17478        && current.context_id == expected.context_id
17479        && current.context == expected.context
17480        && current.exported_context == expected.exported_context
17481        && current.task_binding == expected.task_binding
17482        && current.created_at_unix_ms == expected.created_at_unix_ms
17483        && current.last_error == expected.last_error
17484}
17485
17486fn session_record_context_export_binding_is_exact(
17487    expected: &ManagedSessionRecord,
17488    current: &ManagedSessionRecord,
17489    identity: Option<&ProviderSessionIdentity>,
17490    session: &SessionAddress,
17491    binding: &SessionBinding,
17492    provider: &AgentId,
17493    current_root: &str,
17494    allow_clean_detachment: bool,
17495) -> bool {
17496    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17497        && current.provider_session.as_ref() == identity
17498        && current.workspace_id == session.workspace_id
17499        && provider == &current.provider
17500        && binding.workspace_id == session.workspace_id
17501        && binding.generation == session.session.generation
17502        && binding.record_id.as_ref() == Some(&current.record_id)
17503        && platform::roots_equal(
17504            current_root,
17505            windows_path_text(&current.canonical_root),
17506        )
17507}
17508
17509fn session_record_context_export_source_is_usable(
17510    record: &ManagedSessionRecord,
17511    status: &SessionStatus,
17512    allow_clean_detachment: bool,
17513) -> bool {
17514    (record.state == ManagedSessionState::Live
17515        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17516        && context_pack_source_status_is_usable(status)
17517}
17518
17519fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17520    PromptFraming::BracketedPaste
17521}
17522
17523fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17524    if text.len() > MAX_NODE_TEXT_BYTES {
17525        return Err(failure(
17526            NodeFailureCode::InvalidRequest,
17527            &format!(
17528                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17529                text.len(),
17530            ),
17531        ));
17532    }
17533    Ok(())
17534}
17535
17536fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17537    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17538        return Err(failure(
17539            NodeFailureCode::InvalidRequest,
17540            &format!(
17541                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17542                bytes.len(),
17543            ),
17544        ));
17545    }
17546    Ok(())
17547}
17548
17549fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17550    validate_terminal_bytes(&bytes)?;
17551    Ok(InputAction::TerminalBytes(bytes))
17552}
17553
17554fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17555    if root.is_empty()
17556        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17557        || root.chars().any(char::is_control)
17558    {
17559        return Err(failure(
17560            NodeFailureCode::InvalidWorkspaceRoot,
17561            &format!(
17562                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17563            ),
17564        ));
17565    }
17566    Ok(())
17567}
17568
17569fn canonical_standalone_workspace_candidate(
17570    workspace_id: &WorkspaceId,
17571    root: &str,
17572) -> Result<String, NodeServerError> {
17573    let path = Path::new(root);
17574    match std::fs::symlink_metadata(path) {
17575        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17576            .map(|workspace| workspace.canonical_root),
17577        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17578            if !path.is_absolute() {
17579                return Err(NodeServerError::InvalidWorkspaceRoot {
17580                    workspace_id: workspace_id.clone(),
17581                    path: root.to_owned(),
17582                    message: "path is not absolute".to_owned(),
17583                });
17584            }
17585            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17586                workspace_id: workspace_id.clone(),
17587                path: root.to_owned(),
17588                message: "path has no standalone directory name".to_owned(),
17589            })?;
17590            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17591                workspace_id: workspace_id.clone(),
17592                path: root.to_owned(),
17593                message: "path has no parent directory".to_owned(),
17594            })?;
17595            let canonical = std::fs::canonicalize(parent)
17596                .map(|parent| parent.join(name))
17597                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17598                    workspace_id: workspace_id.clone(),
17599                    path: root.to_owned(),
17600                    message: error.to_string(),
17601                })?;
17602            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17603                NodeServerError::InvalidWorkspaceRoot {
17604                    workspace_id: workspace_id.clone(),
17605                    path: path.to_string_lossy().into_owned(),
17606                    message: "canonical path is not valid Unicode".to_owned(),
17607                }
17608            })?;
17609            let canonical_root = platform::normalize_canonical_root(canonical_root);
17610            validate_workspace_root(workspace_id, &canonical_root)?;
17611            Ok(canonical_root)
17612        }
17613        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17614            workspace_id: workspace_id.clone(),
17615            path: root.to_owned(),
17616            message: error.to_string(),
17617        }),
17618    }
17619}
17620
17621fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17622    let Some(revision) = revision else {
17623        return Ok(());
17624    };
17625    if revision.is_empty()
17626        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17627        || revision.chars().any(char::is_control)
17628    {
17629        return Err(failure(
17630            NodeFailureCode::InvalidRequest,
17631            &format!(
17632                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17633            ),
17634        ));
17635    }
17636    Ok(())
17637}
17638
17639fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17640    let code = match error.kind {
17641        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17642        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17643        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17644        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17645        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17646        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17647        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17648    };
17649    failure(code, &error.message)
17650}
17651
17652fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17653    let code = match error.kind {
17654        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17655        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17656        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17657            NodeFailureCode::BackendOperationFailed
17658        }
17659        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17660            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17661        }
17662    };
17663    failure(code, error.message)
17664}
17665
17666fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17667    let (code, message) = match error.kind {
17668        GitWorktreeErrorKind::Invalid => (
17669            NodeFailureCode::InvalidRequest,
17670            "managed Git input is invalid",
17671        ),
17672        GitWorktreeErrorKind::NotRepository => (
17673            NodeFailureCode::NotGitRepository,
17674            "managed source is not a Git repository",
17675        ),
17676        GitWorktreeErrorKind::Conflict => (
17677            NodeFailureCode::WorktreeConflict,
17678            "managed Git identity conflicts with existing repository state",
17679        ),
17680        GitWorktreeErrorKind::Protected => (
17681            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17682            "managed worktree ownership validation failed",
17683        ),
17684        GitWorktreeErrorKind::Dirty => (
17685            NodeFailureCode::WorktreeDirty,
17686            "managed worktree contains uncommitted changes",
17687        ),
17688        GitWorktreeErrorKind::Locked => (
17689            NodeFailureCode::WorktreeLocked,
17690            "managed worktree is locked",
17691        ),
17692        GitWorktreeErrorKind::Failed => (
17693            NodeFailureCode::BackendOperationFailed,
17694            "managed Git operation failed",
17695        ),
17696    };
17697    NodeFailure { code, message: message.to_owned() }
17698}
17699
17700fn managed_spawn_receipt(
17701    resolved: &ResolvedSpawnSpec,
17702    incarnation_id: NodeIncarnationId,
17703    session: SessionAddress,
17704    lease: ManagedWorktreeLeaseSnapshot,
17705    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17706    bundle: Option<ResolvedBundleReceipt>,
17707    context: Option<ResolvedContextPackReceipt>,
17708) -> ManagedWorktreeSpawnReceipt {
17709    let mut managed = resolved.clone();
17710    managed.target.worktree_id = Some(lease.workspace_id.clone());
17711    ManagedWorktreeSpawnReceipt {
17712        spawn: managed.receipt_with_materialization(
17713            incarnation_id,
17714            session,
17715            environment_profile,
17716            bundle,
17717            context,
17718        ),
17719        lease,
17720    }
17721}
17722
17723fn validate_selected_worktree(
17724    source_root: &str,
17725    selected_root: &str,
17726    worktrees: &[NativeGitWorktreeSnapshot],
17727) -> Result<(), NodeFailure> {
17728    if !worktrees
17729        .iter()
17730        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17731    {
17732        return Err(failure(
17733            NodeFailureCode::WorktreeProtected,
17734            "source workspace is not an authoritative non-bare Git worktree root",
17735        ));
17736    }
17737    if !worktrees.iter().any(|worktree| {
17738        worktree_paths_equal(&worktree.path, selected_root)
17739            && !worktree.is_main
17740            && !worktree.is_bare
17741    }) {
17742        return Err(failure(
17743            NodeFailureCode::WorktreeProtected,
17744            "selected workspace is not an eligible Git worktree in the source repository",
17745        ));
17746    }
17747    Ok(())
17748}
17749
17750fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17751    let code = match error.kind() {
17752        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17753        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17754            NodeFailureCode::RepositoryPathUnsafe
17755        }
17756        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17757        WorkspaceFileReadErrorKind::NotRegularFile => {
17758            NodeFailureCode::RepositoryFileNotRegular
17759        }
17760        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17761            NodeFailureCode::RepositoryFileReadFailed
17762        }
17763        WorkspaceFileReadErrorKind::RevisionConflict => {
17764            NodeFailureCode::RepositoryFileRevisionConflict
17765        }
17766        WorkspaceFileReadErrorKind::AlreadyExists
17767        | WorkspaceFileReadErrorKind::ParentNotFound
17768        | WorkspaceFileReadErrorKind::ParentNotDirectory
17769        | WorkspaceFileReadErrorKind::Canceled => {
17770            NodeFailureCode::RepositoryFileReadFailed
17771        }
17772    };
17773    failure(code, "repository file read failed")
17774}
17775
17776fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17777    let code = match error.kind() {
17778        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17779        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17780            NodeFailureCode::RepositoryPathUnsafe
17781        }
17782        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17783        WorkspaceFileReadErrorKind::NotRegularFile => {
17784            NodeFailureCode::RepositoryFileNotRegular
17785        }
17786        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17787            NodeFailureCode::RepositoryFileWriteFailed
17788        }
17789        WorkspaceFileReadErrorKind::RevisionConflict => {
17790            NodeFailureCode::RepositoryFileRevisionConflict
17791        }
17792        WorkspaceFileReadErrorKind::AlreadyExists
17793        | WorkspaceFileReadErrorKind::ParentNotFound
17794        | WorkspaceFileReadErrorKind::ParentNotDirectory
17795        | WorkspaceFileReadErrorKind::Canceled => {
17796            NodeFailureCode::RepositoryFileWriteFailed
17797        }
17798    };
17799    failure(code, "repository file write failed")
17800}
17801
17802async fn settle_workspace_entry_create<T>(
17803    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17804    commit_state: Arc<AtomicU8>,
17805    deadline: Duration,
17806    timeout_message: &'static str,
17807    task_failure_message: &'static str,
17808) -> Result<T, NodeFailure> {
17809    let joined = match timeout(deadline, &mut task).await {
17810        Ok(joined) => joined,
17811        Err(_) => {
17812            if commit_state
17813                .compare_exchange(
17814                    WORKSPACE_ENTRY_CREATE_PENDING,
17815                    WORKSPACE_ENTRY_CREATE_CANCELED,
17816                    Ordering::AcqRel,
17817                    Ordering::Acquire,
17818                )
17819                .is_ok()
17820            {
17821                return Err(failure(
17822                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17823                    timeout_message,
17824                ));
17825            }
17826            debug_assert_eq!(
17827                commit_state.load(Ordering::Acquire),
17828                WORKSPACE_ENTRY_CREATE_COMMITTING,
17829            );
17830            task.await
17831        }
17832    };
17833    joined
17834        .map_err(|_| {
17835            failure(
17836                NodeFailureCode::RepositoryEntryCreateFailed,
17837                task_failure_message,
17838            )
17839        })?
17840        .map_err(workspace_entry_create_failure)
17841}
17842
17843fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17844    let code = match error.kind() {
17845        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17846        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17847            NodeFailureCode::RepositoryPathUnsafe
17848        }
17849        WorkspaceFileReadErrorKind::AlreadyExists => {
17850            NodeFailureCode::RepositoryEntryAlreadyExists
17851        }
17852        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17853            NodeFailureCode::RepositoryParentNotFound
17854        }
17855        WorkspaceFileReadErrorKind::ParentNotDirectory
17856        | WorkspaceFileReadErrorKind::NotRegularFile => {
17857            NodeFailureCode::RepositoryParentNotDirectory
17858        }
17859        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17860            NodeFailureCode::RepositoryEntryCreateFailed
17861        }
17862        WorkspaceFileReadErrorKind::RevisionConflict => {
17863            NodeFailureCode::RepositoryEntryCreateFailed
17864        }
17865        WorkspaceFileReadErrorKind::Canceled => {
17866            NodeFailureCode::RepositoryEntryCreateTimedOut
17867        }
17868    };
17869    failure(code, "repository entry create failed")
17870}
17871
17872fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17873    let value = digest(&SHA256, bytes)
17874        .as_ref()
17875        .iter()
17876        .map(|byte| format!("{byte:02x}"))
17877        .collect();
17878    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17879}
17880
17881fn managed_spawn_request_digest_v2(
17882    request: &ManagedWorktreeSpawnRequestV2,
17883) -> Result<String, NodeFailure> {
17884    let encoded = serde_json::to_vec(request).map_err(|_| {
17885        failure(
17886            NodeFailureCode::InvalidRequest,
17887            "managed worktree V2 request could not be canonicalized",
17888        )
17889    })?;
17890    let mut value = String::with_capacity(71);
17891    value.push_str("sha256:");
17892    for byte in digest(&SHA256, &encoded).as_ref() {
17893        use std::fmt::Write as _;
17894        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17895    }
17896    Ok(value)
17897}
17898
17899fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17900    let code = match error {
17901        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17902        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17903        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17904        NativeSessionPreviewError::UnsupportedProvider
17905        | NativeSessionPreviewError::PreviewUnavailable
17906        | NativeSessionPreviewError::SessionNotFound
17907        | NativeSessionPreviewError::AmbiguousSession => {
17908            NodeFailureCode::BackendOperationFailed
17909        }
17910    };
17911    failure(code, "native session preview failed")
17912}
17913
17914fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17915    let code = match error.kind() {
17916        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17917        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17918    };
17919    failure(code, "host directory browse failed")
17920}
17921
17922fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17923    let code = match error {
17924        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17925        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17926        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17927            NodeFailureCode::DeliveryStageConflict
17928        }
17929        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17930        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17931            NodeFailureCode::DeliveryChunkOutOfOrder
17932        }
17933        DeliveryStoreError::BlobDigestMismatch => {
17934            NodeFailureCode::DeliveryBlobDigestMismatch
17935        }
17936        DeliveryStoreError::BundleDigestMismatch => {
17937            NodeFailureCode::DeliveryBundleDigestMismatch
17938        }
17939        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17940        DeliveryStoreError::Unavailable
17941        | DeliveryStoreError::Corrupt
17942        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17943    };
17944    failure(code, "delivery operation failed")
17945}
17946
17947fn validate_workspace_root(
17948    workspace_id: &WorkspaceId,
17949    canonical_root: &str,
17950) -> Result<(), NodeServerError> {
17951    #[cfg(windows)]
17952    if canonical_root.starts_with(r"\\") {
17953        return Err(NodeServerError::InvalidWorkspaceRoot {
17954            workspace_id: workspace_id.clone(),
17955            path: canonical_root.to_owned(),
17956            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17957        });
17958    }
17959    if !platform::workspace_root_supported(canonical_root)
17960        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17961    {
17962        return Err(NodeServerError::InvalidWorkspaceRoot {
17963            workspace_id: workspace_id.clone(),
17964            path: canonical_root.to_owned(),
17965            message: "canonical path must be absolute, bounded, and free of control characters"
17966                .to_owned(),
17967        });
17968    }
17969    Ok(())
17970}
17971
17972fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17973    NodeFailure {
17974        code,
17975        message: node_failure_category(code).to_owned(),
17976    }
17977}
17978
17979fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17980    let code = match error {
17981        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17982        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17983        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17984        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17985        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17986        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17987        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17988        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17989        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17990    };
17991    failure(code, "harness MCP proxy operation failed")
17992}
17993
17994/// Numbers each harness-MCP overlay this process constructs, in
17995/// construction order, so two overlays built back to back in one node process
17996/// still get distinct trace files.
17997static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17998
17999/// Builds the optional trace environment entry of one harness-MCP overlay.
18000/// The caller's launch description names both the entry and the node-process
18001/// variable that opts in: when that variable holds a non-empty directory, the
18002/// entry names a trace file under it. Returns `None` when the launch has no
18003/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18004/// the directory; that is the operator's job.
18005fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18006    let trace = launch.trace.as_ref()?;
18007    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18008    if trace_dir.is_empty() {
18009        return None;
18010    }
18011    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18012    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18013    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18014}
18015
18016fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18017    let remaining = deadline.saturating_duration_since(Instant::now());
18018    if remaining.is_zero() {
18019        Err(failure(
18020            NodeFailureCode::SpawnDeadlineExceeded,
18021            "spawn deadline elapsed",
18022        ))
18023    } else {
18024        Ok(remaining)
18025    }
18026}
18027
18028fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18029    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18030    match deadline {
18031        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18032        None => Ok(bounded),
18033    }
18034}
18035
18036fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18037    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18038        failure(
18039            NodeFailureCode::SpawnDeadlineExceeded,
18040            "spawn dispatch exceeded the spawn deadline",
18041        )
18042    } else {
18043        error
18044    }
18045}
18046
18047fn spawn_runtime_capabilities(
18048    capabilities: &SpawnRequiredCapabilities,
18049) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18050    capabilities
18051        .iter()
18052        .map(|capability| match capability.as_str() {
18053            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18054            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18055            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18056            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18057                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18058            }
18059            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18060            _ => Err(failure(
18061                NodeFailureCode::UnsupportedSpawnCapability,
18062                "spawn required capability is unknown",
18063            )),
18064        })
18065        .collect()
18066}
18067
18068fn persistence_failure(_error: io::Error) -> NodeFailure {
18069    NodeFailure {
18070        code: NodeFailureCode::BackendOperationFailed,
18071        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18072    }
18073}
18074
18075fn node_failure_category(code: NodeFailureCode) -> &'static str {
18076    match code {
18077        NodeFailureCode::InvalidRequest => "invalid-request",
18078        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18079        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18080        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18081        NodeFailureCode::ReservationConflict => "reservation-conflict",
18082        NodeFailureCode::ReservationExpired => "reservation-expired",
18083        NodeFailureCode::BindingMismatch => "binding-mismatch",
18084        NodeFailureCode::NotActivated => "not-activated",
18085        NodeFailureCode::CallNotFound => "call-not-found",
18086        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18087        NodeFailureCode::ResponseTooLarge => "response-too-large",
18088        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18089        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18090        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18091        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18092        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18093        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18094        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18095        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18096        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18097        NodeFailureCode::Unauthorized => "unauthorized",
18098        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18099        NodeFailureCode::ControllerBusy => "controller-busy",
18100        NodeFailureCode::ControllerRequired => "controller-required",
18101        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18102        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18103        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18104        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18105        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18106        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18107        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18108        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18109        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18110        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18111        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18112        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18113        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18114        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18115        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18116        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18117        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18118        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18119        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18120        NodeFailureCode::GitReadFailed => "git-read-failed",
18121        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18122        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18123        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18124        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18125        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18126        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18127        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18128        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18129        NodeFailureCode::TurnInFlight => "turn-in-flight",
18130        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18131        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18132        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18133            "unsupported-network-allowlist-mapping"
18134        }
18135        NodeFailureCode::BrowserStationProbeUnavailable => {
18136            "browser-station-probe-unavailable"
18137        }
18138        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18139        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18140        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18141            "environment-profile-binding-mismatch"
18142        }
18143        NodeFailureCode::UnknownBundle => "unknown-bundle",
18144        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18145        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18146        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18147        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18148        NodeFailureCode::ContextPackMaterializationFailed => {
18149            "context-pack-materialization-failed"
18150        }
18151        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18152        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18153        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18154        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18155        NodeFailureCode::LastWorkspace => "last-workspace",
18156        NodeFailureCode::NotGitRepository => "not-git-repository",
18157        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18158        NodeFailureCode::WorktreeProtected => "worktree-protected",
18159        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18160        NodeFailureCode::WorktreeLocked => "worktree-locked",
18161        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18162        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18163        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18164        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18165            "managed-worktree-profile-revision-mismatch"
18166        }
18167        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18168        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18169            "standalone-workspace-recovery-required"
18170        }
18171        NodeFailureCode::UnknownSession => "unknown-session",
18172        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18173        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18174        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18175        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18176        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18177        NodeFailureCode::WorkspaceRegistrationRequired => {
18178            "workspace-registration-required"
18179        }
18180        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18181        NodeFailureCode::StaleGeneration => "stale-generation",
18182        NodeFailureCode::BackendBusy => "backend-busy",
18183        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18184        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18185        NodeFailureCode::ShuttingDown => "shutting-down",
18186    }
18187}
18188
18189fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18190    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18191}
18192
18193fn durable_state_load_error(error: io::Error) -> NodeServerError {
18194    let category = match session_registry::state_load_refusal(&error) {
18195        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18196            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18197        }
18198        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18199            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18200        }
18201        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18202            DURABLE_STATE_CONFLICT_ERROR
18203        }
18204        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18205    };
18206    durable_state_server_error(error, category)
18207}
18208
18209fn unix_time_ms() -> u64 {
18210    std::time::SystemTime::now()
18211        .duration_since(std::time::UNIX_EPOCH)
18212        .unwrap_or_default()
18213        .as_millis()
18214        .min(u64::MAX as u128) as u64
18215}
18216
18217fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18218    if record.provider_session.is_some() {
18219        ManagedSessionState::Dormant
18220    } else {
18221        ManagedSessionState::Unavailable
18222    }
18223}
18224
18225fn active_record_state(
18226    identity_admitted: bool,
18227    provider_session_is_known: bool,
18228) -> ManagedSessionState {
18229    if identity_admitted && !provider_session_is_known {
18230        ManagedSessionState::IdentityPending
18231    } else {
18232        ManagedSessionState::Live
18233    }
18234}
18235
18236fn managed_worktree_record_holders(
18237    records: &[ManagedSessionRecord],
18238) -> Vec<(SessionRecordId, WorkspaceId)> {
18239    records
18240        .iter()
18241        .filter(|record| record.provider_session.is_some())
18242        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18243        .collect()
18244}
18245
18246fn managed_spawn_record_ownership(
18247    binding: &SessionBinding,
18248    effective_runtime_policy: ProviderRuntimePolicy,
18249) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18250    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18251    if effective_runtime_policy.provider_session_identity {
18252        (binding.record_id.clone(), None)
18253    } else {
18254        (None, binding.record_id.clone())
18255    }
18256}
18257
18258#[derive(Debug, Error)]
18259pub enum NodeServerError {
18260    #[error("node endpoint must be a bounded absolute local transport path")]
18261    InvalidEndpoint,
18262    #[error("node access token must contain 1..=4096 bytes")]
18263    InvalidAccessToken,
18264    #[error("node HTTP observer must listen on a loopback address: {0}")]
18265    InvalidApiListen(std::net::SocketAddr),
18266    #[error("node browser bridge must listen on a loopback address: {0}")]
18267    InvalidBridgeListen(std::net::SocketAddr),
18268    #[error("node browser bridge token must contain 1..=4096 bytes")]
18269    InvalidBridgeToken,
18270    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18271    BridgeUnderlayRequiresBridgeListen,
18272    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18273    BridgeUnderlayRequiresBridgeToken,
18274    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18275    InvalidBridgeUnderlayToken,
18276    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18277    InvalidBridgeUnderlayKey,
18278    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18279    InvalidCallHome(std::net::SocketAddr),
18280    #[error("node could not reach its call-home relay: {0}")]
18281    CallHomeTransport(#[source] std::io::Error),
18282    #[error("node durable state path must be an absolute file path: {0}")]
18283    InvalidStatePath(String),
18284    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18285    InvalidHarnessMcpHelper,
18286    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18287    InvalidNetworkAllowlistCatalogPath(String),
18288    #[error("network allowlist catalog could not be read: {0}")]
18289    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18290    #[error("network allowlist catalog line {line}: {message}")]
18291    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18292    #[error("network allowlist catalog: {0}")]
18293    InvalidNetworkAllowlistCatalog(String),
18294    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18295    NetworkAllowlistCatalogCapacity { max: usize },
18296    #[error("the local state directory is unavailable; supply an explicit state path")]
18297    LocalStateDirectoryUnavailable,
18298    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18299    LocalRuntimeDirectoryUnavailable,
18300    #[error("node durable state failed: {0}")]
18301    DurableState(io::Error),
18302    #[error("node requires at least one configured workspace")]
18303    NoWorkspaces,
18304    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18305    InvalidWorkspaceRoot {
18306        workspace_id: WorkspaceId,
18307        path: String,
18308        message: String,
18309    },
18310    #[error("duplicate workspace ID: {0}")]
18311    DuplicateWorkspaceId(WorkspaceId),
18312    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18313    DuplicateWorkspaceRoot {
18314        first: WorkspaceId,
18315        second: WorkspaceId,
18316        root: String,
18317    },
18318    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18319    ManagedWorktreeProfileRequired(WorkspaceId),
18320    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18321    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18322    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18323    DuplicateManagedWorktreeProfile {
18324        workspace_id: WorkspaceId,
18325        profile_id: WorktreeProfileId,
18326    },
18327    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18328    ManagedWorktreeProfileCapacity {
18329        workspace_id: WorkspaceId,
18330        max: usize,
18331    },
18332    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18333    InvalidManagedWorktreeProfile {
18334        workspace_id: WorkspaceId,
18335        profile_id: WorktreeProfileId,
18336        message: String,
18337    },
18338    #[error("duplicate node environment profile: {0}")]
18339    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18340    #[error("node environment profile provider is unavailable: {0}")]
18341    EnvironmentProfileProviderUnavailable(AgentId),
18342    #[error("node environment profile capacity is {max}")]
18343    EnvironmentProfileCapacity { max: usize },
18344    #[error("node environment profiles reuse native profile ID '{0}'")]
18345    DuplicateNativeEnvironmentProfile(String),
18346    #[error("node native environment profile is invalid: {0}")]
18347    NativeEnvironmentProfile(String),
18348    #[error("node session-environment materialization root must be absolute")]
18349    InvalidSessionEnvironmentRoot,
18350    #[error("node session-environment materializer is unavailable")]
18351    SessionEnvironmentMaterializerRequired,
18352    #[error("node session-environment materializer failed to initialize")]
18353    SessionEnvironmentMaterializer,
18354    #[error("node bundle catalog is invalid: {0}")]
18355    BundleCatalog(String),
18356    #[error("node delivery store failed to initialize: {0}")]
18357    DeliveryStore(String),
18358    #[error("node context pack store failed to initialize")]
18359    ContextPackStore,
18360    #[error("active agent registry failed: {0}")]
18361    Registry(String),
18362    #[error("node provider contract manifest is invalid: {0}")]
18363    ProviderContractManifest(String),
18364    #[error("named pipe I/O failed: {0}")]
18365    Io(#[from] io::Error),
18366    #[error("node HTTP observer failed: {0}")]
18367    HttpApi(io::Error),
18368    #[error("node browser bridge failed: {0}")]
18369    BridgeApi(io::Error),
18370    #[error(transparent)]
18371    Frame(#[from] FrameError),
18372    #[error("node handshake failed: {0}")]
18373    Handshake(String),
18374    #[error("node authentication frame was not received before the bounded deadline")]
18375    AuthenticationTimedOut,
18376    #[error("node authentication primitive failed: {0}")]
18377    Authentication(String),
18378    #[error("node incarnation identity generation failed: {0}")]
18379    IncarnationIdentity(String),
18380    #[error("node pre-authentication limiter is closed")]
18381    PreauthClosed,
18382    #[error("node authenticated connection limit was reached")]
18383    AuthenticatedConnectionLimit,
18384    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18385    SignalStreamClosed,
18386    #[error("node stop-all dispatch failed: {0:?}")]
18387    ShutdownDispatch(NodeFailure),
18388    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18389    ShutdownTimedOut { active_native_sessions: usize },
18390}
18391
18392impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18393    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18394        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18395        match error {
18396            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18397            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18398            E::InvalidEntry { line, message } => {
18399                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18400            }
18401            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18402            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18403        }
18404    }
18405}
18406
18407#[cfg(test)]
18408mod agent_stream_blocked_tests {
18409    use super::*;
18410    use gate4agent_types::{
18411        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18412        ProviderSource,
18413    };
18414
18415    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18416        ProviderSource {
18417            family,
18418            binding: AdapterBinding::new(
18419                AdapterId::new(adapter).unwrap(),
18420                "fixture/v1",
18421                AdapterVerification::SyntheticFixture,
18422            )
18423            .unwrap(),
18424        }
18425    }
18426
18427    fn provider_control_event_at(
18428        family: AdapterFamily,
18429        adapter: &str,
18430        source_sequence: u64,
18431        provider_event: ProviderEvent,
18432    ) -> ControlEvent {
18433        ControlEvent {
18434            sequence: 41,
18435            command_id: None,
18436            instance_id: AgentInstanceId(7),
18437            generation: SessionGeneration(3),
18438            event: ControlEventKind::ProviderEvent {
18439                sequence: 8,
18440                source: provider_source(family, adapter),
18441                source_sequence,
18442                event: provider_event,
18443            },
18444        }
18445    }
18446
18447    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18448        provider_control_event_at(family, "fixture", 9, provider_event)
18449    }
18450
18451    fn denied_host_request(
18452        by: ProviderHostDecisionAuthority,
18453        reason: Option<String>,
18454    ) -> ControlEvent {
18455        provider_control_event_at(
18456            AdapterFamily::Acp,
18457            "claude-code",
18458            2,
18459            ProviderEvent::HostRequestObserved {
18460                method: "terminal/create".to_owned(),
18461                params_json: String::new(),
18462                decision: ProviderHostRequestDecision::Denied { by },
18463                outcome: ProviderHostRequestOutcome::Executed,
18464                reason,
18465            },
18466        )
18467    }
18468
18469    fn blocked(
18470        event: &ControlEvent,
18471    ) -> (
18472        Option<String>,
18473        String,
18474        BlockAuthorityV1,
18475        Option<String>,
18476        String,
18477        Option<String>,
18478    ) {
18479        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18480        chunk.validate().expect("minted chunk must validate");
18481        let AgentStreamChunkKindV1::Blocked {
18482            correlation_id,
18483            tool_class,
18484            authority,
18485            reason_kind,
18486            reason,
18487            help,
18488        } = chunk.kind
18489        else {
18490            panic!("expected AgentStreamChunkKindV1::Blocked");
18491        };
18492        (correlation_id, tool_class, authority, reason_kind, reason, help)
18493    }
18494
18495    /// Pins the fleet and every adapter each member declares: the fleet is
18496    /// exactly what `active_registry` says, and no member declares a hook
18497    /// adapter (lifecycle hooks are retired).
18498    #[test]
18499    fn the_enabled_provider_matrix_is_exact() {
18500        let registry = active_registry().unwrap();
18501        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18502        assert_eq!(
18503            providers
18504                .iter()
18505                .map(|contract| contract.provider.as_str())
18506                .collect::<Vec<_>>(),
18507            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18508            vec!["shell", "claude", "codex", "grok", "kimi"],
18509        );
18510        assert!(adapters.iter().all(|contract| {
18511            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18512        }));
18513    }
18514
18515    /// A gate-denied host request, WITH its reason text carried all the way
18516    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18517    #[test]
18518    fn gate_denied_host_request_mints_a_blocked_chunk() {
18519        let gate_text =
18520            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18521        let event = denied_host_request(
18522            ProviderHostDecisionAuthority::Gate,
18523            Some(gate_text.to_owned()),
18524        );
18525        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18526        assert_eq!(chunk.source_sequence, 2);
18527        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18528        assert_eq!(authority, BlockAuthorityV1::HostGate);
18529        assert_eq!(reason, gate_text);
18530        assert_eq!(correlation_id, None);
18531        assert_eq!(reason_kind, None);
18532        assert_eq!(help, None);
18533        assert_eq!(tool_class, "Write");
18534    }
18535
18536    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.";
18537
18538    /// The owner's classifier sample, verbatim, arriving as a
18539    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18540    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18541    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18542    /// and the guidance tail as `help`.
18543    #[test]
18544    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18545        let event = provider_control_event(
18546            AdapterFamily::Acp,
18547            ProviderEvent::ToolCompleted {
18548                id: "toolu_9".to_owned(),
18549                output: CLASSIFIER_SAMPLE.to_owned(),
18550                is_error: true,
18551                duration_ms: None,
18552                agent_id: None,
18553                non_execution_kind: Some("permission-rule".to_owned()),
18554            },
18555        );
18556        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18557        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18558        assert_eq!(reason, "Blocked by classifier");
18559        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18560        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18561        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18562    }
18563
18564    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18565    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18566    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18567    /// vendor code as `reason_kind`.
18568    #[test]
18569    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18570        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.";
18571        let event = provider_control_event(
18572            AdapterFamily::Acp,
18573            ProviderEvent::SessionEnded {
18574                result: "JSON-RPC error -32603: Internal error".to_owned(),
18575                cost_usd: None,
18576                is_error: true,
18577                stop_reason: Some(ProviderStopReason::ProviderError {
18578                    code: -32603,
18579                    message: message.to_owned(),
18580                    vendor_code: Some("usageLimitExceeded".to_owned()),
18581                }),
18582            },
18583        );
18584        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18585        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18586        assert_eq!(reason, message);
18587        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18588        assert_eq!(tool_class, "Session");
18589    }
18590
18591    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18592    /// `authority: ProviderRefusal`.
18593    #[test]
18594    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18595        let event = provider_control_event(
18596            AdapterFamily::Acp,
18597            ProviderEvent::SessionEnded {
18598                result: "refusal".to_owned(),
18599                cost_usd: None,
18600                is_error: true,
18601                stop_reason: Some(ProviderStopReason::Refusal),
18602            },
18603        );
18604        let (_, _, authority, _, _, _) = blocked(&event);
18605        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18606    }
18607
18608    /// A failed tool completion with no typed `non_execution_kind` -- or one
18609    /// that only says the turn stopped -- is a plain execution failure, never a
18610    /// block; nor is an agent's own narrative claiming one.
18611    #[test]
18612    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18613        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18614            let event = provider_control_event(
18615                AdapterFamily::Acp,
18616                ProviderEvent::ToolCompleted {
18617                    id: "toolu_1".to_owned(),
18618                    output: "command exited with status 1".to_owned(),
18619                    is_error: true,
18620                    duration_ms: Some(12),
18621                    agent_id: None,
18622                    non_execution_kind,
18623                },
18624            );
18625            assert!(agent_stream_chunk(&event).is_none());
18626        }
18627        let narrative = provider_control_event(
18628            AdapterFamily::Acp,
18629            ProviderEvent::ToolCompleted {
18630                id: "toolu_2".to_owned(),
18631                output: "the harness blocked the write, approval needed".to_owned(),
18632                is_error: false,
18633                duration_ms: Some(3),
18634                agent_id: None,
18635                non_execution_kind: None,
18636            },
18637        );
18638        assert!(agent_stream_chunk(&narrative).is_none());
18639    }
18640
18641    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18642    /// execution outcome: a granted request that failed while running was
18643    /// still authorized.
18644    #[test]
18645    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18646        for (decision, outcome) in [
18647            (
18648                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18649                ProviderHostRequestOutcome::Executed,
18650            ),
18651            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18652            (
18653                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18654                ProviderHostRequestOutcome::Failed {
18655                    error: "terminal/create spawn failed: os error 3".to_owned(),
18656                },
18657            ),
18658        ] {
18659            let event = provider_control_event_at(
18660                AdapterFamily::Acp,
18661                "claude-code",
18662                3,
18663                ProviderEvent::HostRequestObserved {
18664                    method: "fs/read_text_file".to_owned(),
18665                    params_json: String::new(),
18666                    decision: decision.clone(),
18667                    outcome,
18668                    reason: None,
18669                },
18670            );
18671            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18672        }
18673    }
18674
18675    /// A deferred `session/request_permission` resolved by an operator or an
18676    /// expired deadline carries no reason text -- the chunk falls back to a
18677    /// categorical sentence naming the authority rather than inventing detail.
18678    #[test]
18679    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18680        for (by, expected_authority, expected_reason) in [
18681            (
18682                ProviderHostDecisionAuthority::Operator,
18683                BlockAuthorityV1::Operator,
18684                "declined by the operator",
18685            ),
18686            (
18687                ProviderHostDecisionAuthority::DeadlinePolicy,
18688                BlockAuthorityV1::HostDeadline,
18689                "denied by host policy after the operator deadline expired",
18690            ),
18691        ] {
18692            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18693            assert_eq!(authority, expected_authority);
18694            assert_eq!(reason, expected_reason);
18695        }
18696    }
18697
18698    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18699    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18700    /// outright rather than truncating.
18701    #[test]
18702    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18703        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18704        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18705            ProviderHostDecisionAuthority::Policy,
18706            Some(oversized),
18707        ));
18708        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18709    }
18710}
18711
18712#[cfg(test)]
18713mod standalone_capability_platform_tests {
18714    use super::*;
18715
18716    #[test]
18717    fn standalone_workspace_capability_requires_windows_identity_guards() {
18718        let capabilities = baseline_capabilities().unwrap();
18719        let advertised = capabilities.iter().any(|capability| {
18720            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18721        });
18722        assert_eq!(advertised, cfg!(windows));
18723
18724        let request = NodeRequest::CreateStandaloneWorkspace {
18725            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18726            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18727            initial_branch: None,
18728        };
18729        assert_eq!(
18730            request_uses_unnegotiated_capability(&request, &capabilities),
18731            !cfg!(windows),
18732        );
18733    }
18734}
18735
18736
18737#[cfg(test)]
18738mod station_knobs_resolve_tests {
18739    use super::*;
18740    use gate4agent_catalog::EnvMutation;
18741    use gate4agent_runtime_native::{
18742        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18743        NativeLaunchProfileId,
18744    };
18745    use std::ffi::OsString;
18746    use std::sync::Arc;
18747
18748    struct EmptyEnvironmentResolver;
18749
18750    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18751        fn resolve_child_environment(
18752            &self,
18753        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18754            Ok(vec![EnvMutation {
18755                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18756                value: None,
18757            }])
18758        }
18759    }
18760
18761    fn agent(value: &str) -> AgentId {
18762        AgentId::new(value).unwrap()
18763    }
18764
18765    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18766        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18767        let profile_revision =
18768            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18769        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18770            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18771            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18772            provider: agent("claude"),
18773            mode: SessionMode::Pty,
18774            terminal_size: gate4agent_types::TerminalSize {
18775                rows: 24,
18776                columns: 80,
18777            },
18778            prompt: None,
18779            bundle_id: None,
18780            context_id: None,
18781            environment_profile_id: Some(profile_id.clone()),
18782        };
18783        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18784        let catalog = active_registry().unwrap();
18785        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18786        let control = runtime.native_launch_profile_control();
18787        let node_profile = NodeEnvironmentProfile::new(
18788            profile_id.clone(),
18789            profile_revision,
18790            agent("claude"),
18791            [NativeLaunchProfile::new(
18792                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18793                agent("claude"),
18794                TransportKind::Pty,
18795                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18796                Arc::new(EmptyEnvironmentResolver),
18797            )
18798            .unwrap()],
18799        )
18800        .unwrap();
18801        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18802        for native_profile in native_profiles {
18803            runtime.upsert_native_launch_profile(native_profile).unwrap();
18804        }
18805        let workspace = WorkspaceConfig::new(
18806            WorkspaceId::new("primary").unwrap(),
18807            std::env::current_dir().unwrap(),
18808        )
18809        .unwrap();
18810        let shared = NodeShared::new_with_incarnation(
18811            handle,
18812            "fixture-token".to_owned(),
18813            NodeId::new("node-station-knobs-test").unwrap(),
18814            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18815            vec![workspace],
18816            vec![agent("claude")],
18817            ProviderRuntimeStatuses::default(),
18818            None,
18819            Vec::new(),
18820            Vec::new(),
18821            spawn_profiles,
18822            Some(control),
18823            None,
18824            None,
18825            None,
18826            Vec::new(),
18827            Vec::new(),
18828            Vec::new(),
18829            Vec::new(),
18830            None,
18831            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18832        );
18833        shared
18834            .environment_profiles
18835            .write()
18836            .unwrap_or_else(|poisoned| poisoned.into_inner())
18837            .insert(binding.id.clone(), binding);
18838        (shared, spawn_defaults)
18839    }
18840
18841    /// Empty-default catalog refuses unknown network_allowlist ids.
18842    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18843    #[test]
18844    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18845        let (shared, defaults) = station_knobs_fixture();
18846        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18847        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18848        let spec = crate::protocol::SpawnSpec {
18849            target: crate::protocol::SpawnTarget {
18850                node_id: shared.node_id.clone(),
18851                workspace_id: WorkspaceId::new("primary").unwrap(),
18852                worktree_id: None,
18853            },
18854            profile_id: defaults.profile_id.clone(),
18855            expected_profile_revision: defaults.revision.clone(),
18856            overrides: crate::protocol::SpawnOverrides {
18857                network_allowlist: Some(unknown),
18858                browser_profile_id: Some(browser),
18859                ..crate::protocol::SpawnOverrides::default()
18860            },
18861            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18862            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18863                "station-knobs-unknown-allowlist",
18864            )
18865            .unwrap(),
18866            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18867        };
18868        let resolved = spec.resolve(&defaults).unwrap();
18869        let failure = shared
18870            .resolve_environment_profile(&resolved)
18871            .expect_err("unknown allowlist must refuse");
18872        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18873    }
18874
18875    /// Registered network_allowlist (+ browser_profile_id when feature off)
18876    /// echo onto the env-profile receipt. Empty catalog still allows omit
18877    /// (None). Empty/whitespace refuse is type-level. Feature-off
18878    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18879    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18880    #[test]
18881    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18882        let (shared, defaults) = station_knobs_fixture();
18883        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18884        #[cfg(not(feature = "dig2-station-probe"))]
18885        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18886        shared
18887            .network_allowlist_catalog
18888            .write()
18889            .unwrap_or_else(|poisoned| poisoned.into_inner())
18890            .insert_id(allowlist.clone())
18891            .unwrap();
18892        let spec = crate::protocol::SpawnSpec {
18893            target: crate::protocol::SpawnTarget {
18894                node_id: shared.node_id.clone(),
18895                workspace_id: WorkspaceId::new("primary").unwrap(),
18896                worktree_id: None,
18897            },
18898            profile_id: defaults.profile_id.clone(),
18899            expected_profile_revision: defaults.revision.clone(),
18900            overrides: crate::protocol::SpawnOverrides {
18901                network_allowlist: Some(allowlist.clone()),
18902                #[cfg(not(feature = "dig2-station-probe"))]
18903                browser_profile_id: Some(browser.clone()),
18904                ..crate::protocol::SpawnOverrides::default()
18905            },
18906            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18907            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18908                .unwrap(),
18909            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18910        };
18911        let resolved = spec.resolve(&defaults).unwrap();
18912        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18913        #[cfg(not(feature = "dig2-station-probe"))]
18914        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18915        let receipt = shared
18916            .resolve_environment_profile(&resolved)
18917            .expect("resolve ok")
18918            .expect("env profile present");
18919        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18920        #[cfg(not(feature = "dig2-station-probe"))]
18921        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18922        #[cfg(feature = "dig2-station-probe")]
18923        assert_eq!(receipt.browser_profile_id, None);
18924        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18925
18926        let omitted = crate::protocol::SpawnSpec {
18927            overrides: crate::protocol::SpawnOverrides::default(),
18928            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18929                .unwrap(),
18930            ..spec
18931        };
18932        let resolved_omit = omitted.resolve(&defaults).unwrap();
18933        let bare = shared
18934            .resolve_environment_profile(&resolved_omit)
18935            .expect("resolve ok")
18936            .expect("env profile present");
18937        assert_eq!(bare.network_allowlist, None);
18938        assert_eq!(bare.browser_profile_id, None);
18939    }
18940
18941    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18942    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18943    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18944    /// unit-tested separately (probe refuses before lease on Linux).
18945    #[cfg(feature = "dig2-station-probe")]
18946    #[test]
18947    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18948        let (shared, defaults) = station_knobs_fixture();
18949        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18950        let spec = crate::protocol::SpawnSpec {
18951            target: crate::protocol::SpawnTarget {
18952                node_id: shared.node_id.clone(),
18953                workspace_id: WorkspaceId::new("primary").unwrap(),
18954                worktree_id: None,
18955            },
18956            profile_id: defaults.profile_id.clone(),
18957            expected_profile_revision: defaults.revision.clone(),
18958            overrides: crate::protocol::SpawnOverrides {
18959                browser_profile_id: Some(browser),
18960                ..crate::protocol::SpawnOverrides::default()
18961            },
18962            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18963            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18964                "station-knobs-probe-refuse",
18965            )
18966            .unwrap(),
18967            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18968        };
18969        let resolved = spec.resolve(&defaults).unwrap();
18970        let failure = shared
18971            .resolve_environment_profile(&resolved)
18972            .expect_err("browser_profile with failing probe must refuse");
18973        #[cfg(windows)]
18974        {
18975            assert!(
18976                matches!(
18977                    failure.code,
18978                    NodeFailureCode::BrowserStationUnreachable
18979                        | NodeFailureCode::BrowserStationProbeUnavailable
18980                ),
18981                "unexpected code {:?}",
18982                failure.code
18983            );
18984        }
18985        #[cfg(not(windows))]
18986        {
18987            assert_eq!(
18988                failure.code,
18989                NodeFailureCode::BrowserStationProbeUnavailable
18990            );
18991        }
18992    }
18993
18994    /// Feature on: omitting browser_profile_id still resolves (no probe).
18995    #[cfg(feature = "dig2-station-probe")]
18996    #[test]
18997    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18998        let (shared, defaults) = station_knobs_fixture();
18999        let spec = crate::protocol::SpawnSpec {
19000            target: crate::protocol::SpawnTarget {
19001                node_id: shared.node_id.clone(),
19002                workspace_id: WorkspaceId::new("primary").unwrap(),
19003                worktree_id: None,
19004            },
19005            profile_id: defaults.profile_id.clone(),
19006            expected_profile_revision: defaults.revision.clone(),
19007            overrides: crate::protocol::SpawnOverrides::default(),
19008            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19009            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19010                "station-knobs-probe-omit",
19011            )
19012            .unwrap(),
19013            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19014        };
19015        let resolved = spec.resolve(&defaults).unwrap();
19016        let receipt = shared
19017            .resolve_environment_profile(&resolved)
19018            .expect("omit browser_profile must not probe")
19019            .expect("env profile present");
19020        assert_eq!(receipt.browser_profile_id, None);
19021    }
19022
19023    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19024    /// refuses a second holder for the same opaque profile id; release on
19025    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19026    #[cfg(feature = "dig2-station-probe")]
19027    #[test]
19028    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19029        let (shared, _defaults) = station_knobs_fixture();
19030        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19031        let workspace_id = WorkspaceId::new("primary").unwrap();
19032        let address_a = SessionAddress {
19033            workspace_id: workspace_id.clone(),
19034            session: SessionKey {
19035                instance_id: AgentInstanceId(101),
19036                generation: SessionGeneration(1),
19037            },
19038        };
19039        let address_b = SessionAddress {
19040            workspace_id: workspace_id.clone(),
19041            session: SessionKey {
19042                instance_id: AgentInstanceId(102),
19043                generation: SessionGeneration(1),
19044            },
19045        };
19046        let guard_a = crate::dig2_station_lease::acquire_lease(
19047            &shared.browser_station_leases,
19048            profile.clone(),
19049            address_a.session.instance_id,
19050            1,
19051        )
19052        .expect("first lease");
19053        let busy = crate::dig2_station_lease::acquire_lease(
19054            &shared.browser_station_leases,
19055            profile.clone(),
19056            address_b.session.instance_id,
19057            2,
19058        )
19059        .expect_err("second holder must refuse");
19060        assert!(matches!(
19061            busy,
19062            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19063        ));
19064        // Simulate a bound session so remove_binding clears the lease.
19065        shared.bind_session_with_policy(
19066            &address_a,
19067            ProviderRuntimePolicy::raw_pty(),
19068            None,
19069        );
19070        guard_a.retain();
19071        assert!(shared.remove_binding(&address_a).is_some());
19072        crate::dig2_station_lease::acquire_lease(
19073            &shared.browser_station_leases,
19074            profile,
19075            address_b.session.instance_id,
19076            3,
19077        )
19078        .expect("reacquire after clear")
19079        .retain();
19080        assert!(
19081            crate::dig2_station_lease::release_lease_for_holder(
19082                &shared.browser_station_leases,
19083                address_b.session.instance_id,
19084            )
19085        );
19086    }
19087
19088    /// Feature-off: no browser_station_leases field; stub echo documented in
19089    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19090    #[cfg(not(feature = "dig2-station-probe"))]
19091    #[test]
19092    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19093        let (shared, defaults) = station_knobs_fixture();
19094        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19095        let spec = crate::protocol::SpawnSpec {
19096            target: crate::protocol::SpawnTarget {
19097                node_id: shared.node_id.clone(),
19098                workspace_id: WorkspaceId::new("primary").unwrap(),
19099                worktree_id: None,
19100            },
19101            profile_id: defaults.profile_id.clone(),
19102            expected_profile_revision: defaults.revision.clone(),
19103            overrides: crate::protocol::SpawnOverrides {
19104                browser_profile_id: Some(browser.clone()),
19105                ..crate::protocol::SpawnOverrides::default()
19106            },
19107            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19108            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19109                .unwrap(),
19110            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19111        };
19112        let resolved = spec.resolve(&defaults).unwrap();
19113        let receipt = shared
19114            .resolve_environment_profile(&resolved)
19115            .expect("stub resolve")
19116            .expect("env profile");
19117        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19118    }
19119
19120
19121    /// Startup file loader registers ids into the empty-default catalog.
19122    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19123    #[test]
19124    fn network_allowlist_catalog_loads_from_temp_file() {
19125        let unique = std::time::SystemTime::now()
19126            .duration_since(std::time::UNIX_EPOCH)
19127            .unwrap()
19128            .as_nanos();
19129        let path = std::env::temp_dir().join(format!(
19130            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19131            std::process::id(),
19132        ));
19133        std::fs::write(
19134            &path,
19135            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19136        )
19137        .unwrap();
19138        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19139            .expect("temp catalog file must load");
19140        let _ = std::fs::remove_file(&path);
19141        assert_eq!(loaded.len(), 2);
19142        let (shared, defaults) = station_knobs_fixture();
19143        {
19144            let mut catalog = shared
19145                .network_allowlist_catalog
19146                .write()
19147                .unwrap_or_else(|poisoned| poisoned.into_inner());
19148            *catalog = loaded;
19149        }
19150        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19151        let spec = crate::protocol::SpawnSpec {
19152            target: crate::protocol::SpawnTarget {
19153                node_id: shared.node_id.clone(),
19154                workspace_id: WorkspaceId::new("primary").unwrap(),
19155                worktree_id: None,
19156            },
19157            profile_id: defaults.profile_id.clone(),
19158            expected_profile_revision: defaults.revision.clone(),
19159            overrides: crate::protocol::SpawnOverrides {
19160                network_allowlist: Some(allowlist.clone()),
19161                ..crate::protocol::SpawnOverrides::default()
19162            },
19163            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19164            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19165                "station-knobs-catalog-file",
19166            )
19167            .unwrap(),
19168            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19169        };
19170        let resolved = spec.resolve(&defaults).unwrap();
19171        let receipt = shared
19172            .resolve_environment_profile(&resolved)
19173            .expect("registered via file load")
19174            .expect("env profile present");
19175        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19176    }
19177
19178    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19179    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19180    #[test]
19181    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19182        let (shared, _defaults) = station_knobs_fixture();
19183        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19184        shared
19185            .network_allowlist_catalog
19186            .write()
19187            .unwrap_or_else(|poisoned| poisoned.into_inner())
19188            .insert_id(allowlist.clone())
19189            .unwrap();
19190        let snapshot = shared.snapshot();
19191        let inventory = snapshot
19192            .launch_inventory
19193            .expect("launch inventory present");
19194        let ids = inventory
19195            .network_allowlists
19196            .expect("non-empty catalog surfaces ids");
19197        assert_eq!(ids, vec![allowlist]);
19198    }
19199
19200    /// Track B: registered id with provider_native codex_network_access refuses
19201    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19202    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19203    #[test]
19204    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19205        let (shared, defaults) = station_knobs_fixture();
19206        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19207        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19208            id: allowlist.clone(),
19209            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19210                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19211                peer: "127.0.0.1:443".to_owned(),
19212            }],
19213            provider_native: Some(
19214                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19215                    codex_network_access: Some(true),
19216                },
19217            ),
19218        };
19219        shared
19220            .network_allowlist_catalog
19221            .write()
19222            .unwrap_or_else(|poisoned| poisoned.into_inner())
19223            .insert(entry)
19224            .unwrap();
19225        let spec = crate::protocol::SpawnSpec {
19226            target: crate::protocol::SpawnTarget {
19227                node_id: shared.node_id.clone(),
19228                workspace_id: WorkspaceId::new("primary").unwrap(),
19229                worktree_id: None,
19230            },
19231            profile_id: defaults.profile_id.clone(),
19232            expected_profile_revision: defaults.revision.clone(),
19233            overrides: crate::protocol::SpawnOverrides {
19234                network_allowlist: Some(allowlist),
19235                ..crate::protocol::SpawnOverrides::default()
19236            },
19237            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19238            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19239                "station-knobs-unsupported-mapping",
19240            )
19241            .unwrap(),
19242            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19243        };
19244        let resolved = spec.resolve(&defaults).unwrap();
19245        assert_eq!(resolved.provider.as_str(), "claude");
19246        let failure = shared
19247            .resolve_environment_profile(&resolved)
19248            .expect_err("claude + codex_network_access must refuse");
19249        assert_eq!(
19250            failure.code,
19251            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19252        );
19253    }
19254
19255    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19256    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19257    /// Never secrets on C2.
19258    #[test]
19259    fn resolve_codex_network_access_overlay_moderate_only() {
19260        let (shared, defaults) = station_knobs_fixture();
19261        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19262        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19263            id: allowlist.clone(),
19264            permits: Vec::new(),
19265            provider_native: Some(
19266                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19267                    codex_network_access: Some(true),
19268                },
19269            ),
19270        };
19271        shared
19272            .network_allowlist_catalog
19273            .write()
19274            .unwrap_or_else(|poisoned| poisoned.into_inner())
19275            .insert(entry)
19276            .unwrap();
19277
19278        let overlay = shared
19279            .resolve_codex_network_access_overlay_args(
19280                &agent("codex"),
19281                SessionMode::Pty,
19282                ApprovalLevel::Moderate,
19283                Some(&allowlist),
19284            )
19285            .expect("Moderate Codex maps network_access");
19286        assert_eq!(
19287            overlay,
19288            vec![
19289                std::ffi::OsString::from("-c"),
19290                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19291            ]
19292        );
19293
19294        let acp = shared
19295            .resolve_codex_network_access_overlay_args(
19296                &agent("codex"),
19297                SessionMode::Acp,
19298                ApprovalLevel::Moderate,
19299                Some(&allowlist),
19300            )
19301            .unwrap();
19302        assert_eq!(acp, overlay);
19303
19304        let inline = shared
19305            .resolve_codex_network_access_overlay_args(
19306                &agent("codex"),
19307                SessionMode::Inline,
19308                ApprovalLevel::Moderate,
19309                Some(&allowlist),
19310            )
19311            .unwrap_err();
19312        assert_eq!(
19313            inline.code,
19314            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19315        );
19316
19317        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19318            let spec = crate::protocol::SpawnSpec {
19319                target: crate::protocol::SpawnTarget {
19320                    node_id: shared.node_id.clone(),
19321                    workspace_id: WorkspaceId::new("primary").unwrap(),
19322                    worktree_id: None,
19323                },
19324                profile_id: defaults.profile_id.clone(),
19325                expected_profile_revision: defaults.revision.clone(),
19326                overrides: crate::protocol::SpawnOverrides {
19327                    provider: crate::protocol::SpawnOverride::Set {
19328                        value: agent("codex"),
19329                    },
19330                    approval_level: Some(level),
19331                    network_allowlist: Some(allowlist.clone()),
19332                    ..crate::protocol::SpawnOverrides::default()
19333                },
19334                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19335                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19336                    "codex-net-refuse-{}",
19337                    match level {
19338                        ApprovalLevel::ReadOnly => "ro",
19339                        ApprovalLevel::FullAuto => "fa",
19340                        _ => "other",
19341                    }
19342                ))
19343                .unwrap(),
19344                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19345            };
19346            let resolved = spec.resolve(&defaults).unwrap();
19347            let failure = shared
19348                .resolve_environment_profile(&resolved)
19349                .expect_err("RO/FullAuto must refuse");
19350            assert_eq!(
19351                failure.code,
19352                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19353            );
19354        }
19355    }
19356
19357    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19358    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19359    /// Never secrets on C2; does not invent provider_native Claude keys.
19360    #[test]
19361    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19362        let (shared, _defaults) = station_knobs_fixture();
19363        let allowlist =
19364            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19365        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19366            id: allowlist.clone(),
19367            permits: vec![
19368                crate::network_allowlist_catalog::NetworkPermitSketch {
19369                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19370                    peer: "github.com:443".to_owned(),
19371                },
19372                crate::network_allowlist_catalog::NetworkPermitSketch {
19373                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19374                    peer: "*.npmjs.org".to_owned(),
19375                },
19376            ],
19377            provider_native: None,
19378        };
19379        shared
19380            .network_allowlist_catalog
19381            .write()
19382            .unwrap_or_else(|poisoned| poisoned.into_inner())
19383            .insert(entry)
19384            .unwrap();
19385
19386        let unique = std::time::SystemTime::now()
19387            .duration_since(std::time::UNIX_EPOCH)
19388            .unwrap()
19389            .as_nanos();
19390        let provider_home = std::env::temp_dir().join(format!(
19391            "gate4agent-claude-provider-home-{}-{unique}",
19392            std::process::id(),
19393        ));
19394        std::fs::create_dir_all(&provider_home).unwrap();
19395
19396        let overlay = shared
19397            .resolve_network_allowlist_overlay_args(
19398                &agent("claude"),
19399                SessionMode::Pty,
19400                ApprovalLevel::Moderate,
19401                Some(&allowlist),
19402                Some(provider_home.as_path()),
19403            )
19404            .expect("Claude PTY settings overlay on unix");
19405        let settings_path = provider_home.join(
19406            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19407        );
19408        assert_eq!(
19409            overlay,
19410            vec![
19411                std::ffi::OsString::from("--settings"),
19412                std::ffi::OsString::from(settings_path.as_os_str()),
19413            ]
19414        );
19415        let body = std::fs::read_to_string(&settings_path).unwrap();
19416        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19417        assert_eq!(value["sandbox"]["enabled"], true);
19418        assert_eq!(
19419            value["sandbox"]["network"]["allowedDomains"],
19420            serde_json::json!(["github.com", "*.npmjs.org"])
19421        );
19422        assert!(settings_path.starts_with(&provider_home));
19423
19424        let acp = shared
19425            .resolve_network_allowlist_overlay_args(
19426                &agent("claude"),
19427                SessionMode::Acp,
19428                ApprovalLevel::Moderate,
19429                Some(&allowlist),
19430                Some(provider_home.as_path()),
19431            )
19432            .expect_err("Claude ACP must refuse settings overlay");
19433        assert_eq!(
19434            acp.code,
19435            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19436        );
19437
19438        let no_home = shared
19439            .resolve_network_allowlist_overlay_args(
19440                &agent("claude"),
19441                SessionMode::Pty,
19442                ApprovalLevel::Moderate,
19443                Some(&allowlist),
19444                None,
19445            )
19446            .expect_err("Claude overlay requires ProviderHome");
19447        assert_eq!(
19448            no_home.code,
19449            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19450        );
19451
19452        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19453        shared
19454            .network_allowlist_catalog
19455            .write()
19456            .unwrap_or_else(|poisoned| poisoned.into_inner())
19457            .insert_id(bare.clone())
19458            .unwrap();
19459        let empty = shared
19460            .resolve_network_allowlist_overlay_args(
19461                &agent("claude"),
19462                SessionMode::Pty,
19463                ApprovalLevel::Moderate,
19464                Some(&bare),
19465                Some(provider_home.as_path()),
19466            )
19467            .unwrap();
19468        assert!(empty.is_empty());
19469
19470        let _ = std::fs::remove_dir_all(&provider_home);
19471    }
19472
19473    /// Kimi / Grok with permits stay without invented argv overlays.
19474    #[test]
19475    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19476        let (shared, _defaults) = station_knobs_fixture();
19477        let allowlist =
19478            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19479        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19480            id: allowlist.clone(),
19481            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19482                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19483                peer: "example.com:443".to_owned(),
19484            }],
19485            provider_native: None,
19486        };
19487        shared
19488            .network_allowlist_catalog
19489            .write()
19490            .unwrap_or_else(|poisoned| poisoned.into_inner())
19491            .insert(entry)
19492            .unwrap();
19493        for provider in ["kimi", "grok"] {
19494            let overlay = shared
19495                .resolve_network_allowlist_overlay_args(
19496                    &agent(provider),
19497                    SessionMode::Pty,
19498                    ApprovalLevel::Moderate,
19499                    Some(&allowlist),
19500                    None,
19501                )
19502                .unwrap();
19503            assert!(
19504                overlay.is_empty(),
19505                "{provider} must not invent network argv from permits"
19506            );
19507        }
19508    }
19509
19510    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19511    #[test]
19512    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19513        let unique = std::time::SystemTime::now()
19514            .duration_since(std::time::UNIX_EPOCH)
19515            .unwrap()
19516            .as_nanos();
19517        let path = std::env::temp_dir().join(format!(
19518            "gate4agent-node-allowlist-json-{}-{unique}.json",
19519            std::process::id(),
19520        ));
19521        std::fs::write(
19522            &path,
19523            r#"{
19524              "schema_version": 2,
19525              "entries": [
19526                {
19527                  "id": "egress-default",
19528                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19529                },
19530                { "id": "lab-egress" }
19531              ]
19532            }"#,
19533        )
19534        .unwrap();
19535        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19536            .expect("json v2 catalog must load");
19537        let _ = std::fs::remove_file(&path);
19538        assert_eq!(loaded.len(), 2);
19539        let entry = loaded
19540            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19541            .unwrap();
19542        assert_eq!(entry.permits.len(), 1);
19543        let (shared, defaults) = station_knobs_fixture();
19544        {
19545            let mut catalog = shared
19546                .network_allowlist_catalog
19547                .write()
19548                .unwrap_or_else(|poisoned| poisoned.into_inner());
19549            *catalog = loaded;
19550        }
19551        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19552        let spec = crate::protocol::SpawnSpec {
19553            target: crate::protocol::SpawnTarget {
19554                node_id: shared.node_id.clone(),
19555                workspace_id: WorkspaceId::new("primary").unwrap(),
19556                worktree_id: None,
19557            },
19558            profile_id: defaults.profile_id.clone(),
19559            expected_profile_revision: defaults.revision.clone(),
19560            overrides: crate::protocol::SpawnOverrides {
19561                network_allowlist: Some(allowlist.clone()),
19562                ..crate::protocol::SpawnOverrides::default()
19563            },
19564            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19565            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19566                "station-knobs-catalog-json",
19567            )
19568            .unwrap(),
19569            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19570        };
19571        let resolved = spec.resolve(&defaults).unwrap();
19572        let receipt = shared
19573            .resolve_environment_profile(&resolved)
19574            .expect("permits-only entry ok for claude")
19575            .expect("env profile present");
19576        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19577        let inventory = shared
19578            .snapshot()
19579            .launch_inventory
19580            .expect("launch inventory present");
19581        let ids = inventory.network_allowlists.expect("ids surface");
19582        assert_eq!(
19583            ids,
19584            vec![
19585                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19586                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19587            ]
19588        );
19589    }
19590
19591
19592}
19593
19594
19595#[cfg(test)]
19596mod workspace_environment_collision_tests {
19597    use super::*;
19598    use crate::session_environment::{
19599        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19600    };
19601    use gate4agent_catalog::EnvMutation;
19602    use gate4agent_runtime_native::{
19603        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19604        NativeLaunchProfileId,
19605    };
19606    use std::ffi::OsString;
19607    use std::sync::Arc;
19608
19609    struct EmptyEnvironmentResolver;
19610
19611    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19612        fn resolve_child_environment(
19613            &self,
19614        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19615            Ok(vec![EnvMutation {
19616                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19617                value: None,
19618            }])
19619        }
19620    }
19621
19622    fn agent(value: &str) -> AgentId {
19623        AgentId::new(value).unwrap()
19624    }
19625
19626    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19627    fn claude_config_dir_collision_fixture() -> (
19628        NodeShared,
19629        ResolvedEnvironmentProfileReceipt,
19630        ResolvedEnvironmentProfileReceipt,
19631    ) {
19632        let catalog = active_registry().unwrap();
19633        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19634        let control = runtime.native_launch_profile_control();
19635        let workspace = WorkspaceConfig::new(
19636            WorkspaceId::new("primary").unwrap(),
19637            std::env::current_dir().unwrap(),
19638        )
19639        .unwrap();
19640        let shared = NodeShared::new_with_incarnation(
19641            handle,
19642            "fixture-token".to_owned(),
19643            NodeId::new("node-env-collision-test").unwrap(),
19644            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19645            vec![workspace],
19646            vec![agent("claude")],
19647            ProviderRuntimeStatuses::default(),
19648            None,
19649            Vec::new(),
19650            Vec::new(),
19651            SpawnProfileRegistry::default(),
19652            Some(control),
19653            None,
19654            None,
19655            None,
19656            Vec::new(),
19657            Vec::new(),
19658            Vec::new(),
19659            Vec::new(),
19660            None,
19661            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19662        );
19663
19664        let mut receipts = Vec::new();
19665        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19666            let profile_id =
19667                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19668            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19669                "claude-config-dir-{suffix}-r1"
19670            ))
19671            .unwrap();
19672            let materialization = NodeSessionMaterializationProfile::new(
19673                Vec::new(),
19674                vec![NodeSessionPathBinding::new(
19675                    "CLAUDE_CONFIG_DIR",
19676                    NodeSessionPathClass::ProviderHome,
19677                )
19678                .unwrap()],
19679                Vec::new(),
19680            )
19681            .unwrap();
19682            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19683                profile_id.clone(),
19684                profile_revision.clone(),
19685                agent("claude"),
19686                [NativeLaunchProfile::new(
19687                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19688                        .unwrap(),
19689                    agent("claude"),
19690                    TransportKind::Pty,
19691                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19692                    Arc::new(EmptyEnvironmentResolver),
19693                )
19694                .unwrap()],
19695                Some(materialization),
19696            )
19697            .unwrap();
19698            let (binding, native_profiles, materialization) = node_profile.into_parts();
19699            for native_profile in native_profiles {
19700                runtime.upsert_native_launch_profile(native_profile).unwrap();
19701            }
19702            shared
19703                .environment_profiles
19704                .write()
19705                .unwrap_or_else(|poisoned| poisoned.into_inner())
19706                .insert(binding.id.clone(), binding);
19707            shared
19708                .environment_materialization_profiles
19709                .write()
19710                .unwrap_or_else(|poisoned| poisoned.into_inner())
19711                .insert(profile_id.clone(), materialization.unwrap());
19712            receipts.push(ResolvedEnvironmentProfileReceipt {
19713                profile_id,
19714                profile_revision,
19715                network_allowlist: None,
19716                browser_profile_id: None,
19717            });
19718        }
19719        let profile_b = receipts.pop().unwrap();
19720        let profile_a = receipts.pop().unwrap();
19721        (shared, profile_a, profile_b)
19722    }
19723
19724    #[test]
19725    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19726        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19727        let primary = WorkspaceId::new("primary").unwrap();
19728        let worktree = WorkspaceId::new("worktree-b").unwrap();
19729        let address = SessionAddress {
19730            workspace_id: primary.clone(),
19731            session: SessionKey {
19732                instance_id: AgentInstanceId(41),
19733                generation: SessionGeneration(1),
19734            },
19735        };
19736        shared.bind_session_with_policy(
19737            &address,
19738            ProviderRuntimePolicy::raw_pty(),
19739            Some(profile_a.clone()),
19740        );
19741
19742        let refused = shared
19743            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19744            .unwrap_err();
19745        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19746
19747        // Some vs None on the same cwd is also a collision.
19748        let refused_none = shared
19749            .ensure_workspace_environment_profile_compatible(&primary, None)
19750            .unwrap_err();
19751        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19752
19753        // Same profile on the same workspace remains allowed.
19754        shared
19755            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19756            .unwrap();
19757
19758        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19759        shared
19760            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19761            .unwrap();
19762    }
19763
19764    #[tokio::test]
19765    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19766        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19767        let primary = WorkspaceId::new("primary").unwrap();
19768        let address = SessionAddress {
19769            workspace_id: primary.clone(),
19770            session: SessionKey {
19771                instance_id: AgentInstanceId(41),
19772                generation: SessionGeneration(1),
19773            },
19774        };
19775        shared.bind_session_with_policy(
19776            &address,
19777            ProviderRuntimePolicy::raw_pty(),
19778            Some(profile_a),
19779        );
19780
19781        let failure = shared
19782            .spawn_session_with_deadline(
19783                primary,
19784                agent("claude"),
19785                SessionMode::Pty,
19786                gate4agent_types::TerminalSize {
19787                    rows: 24,
19788                    columns: 80,
19789                },
19790                None,
19791                Some(profile_b),
19792                None,
19793                None,
19794                None,
19795                None,
19796                SpawnRecordPolicy::ProviderIdentityOnly,
19797                Some(Instant::now() + Duration::from_secs(30)),
19798                &[],
19799                None,
19800                ApprovalLevel::default(),
19801                None,
19802                None,
19803            )
19804            .await
19805            .unwrap_err();
19806        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19807        // Guard runs before instance id mint / bind.
19808        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19809        assert!(shared.handle.snapshot().sessions.is_empty());
19810    }
19811}
19812
19813#[cfg(test)]
19814mod spawn_characterization_tests {
19815    use super::*;
19816    use crate::session_environment::{
19817        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19818        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19819        NodeSessionPathClass,
19820    };
19821    use gate4agent_catalog::EnvMutation;
19822    use gate4agent_runtime_native::{
19823        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19824        NativeLaunchProfile, NativeLaunchProfileId,
19825    };
19826    use std::ffi::OsString;
19827    use std::sync::Arc;
19828
19829    struct EmptyEnvironmentResolver;
19830
19831    struct FixtureSecretResolver {
19832        deny: Arc<AtomicBool>,
19833    }
19834
19835    impl NodeSecretResolver for FixtureSecretResolver {
19836        fn resolve(
19837            &self,
19838            _reference: &NodeSecretReference,
19839        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19840            if self.deny.load(Ordering::Acquire) {
19841                Err(NodeSecretResolveError::Denied)
19842            } else {
19843                NodeSecretValue::text("fixture-secret")
19844                    .map_err(|_| NodeSecretResolveError::Unavailable)
19845            }
19846        }
19847    }
19848
19849    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19850        fn resolve_child_environment(
19851            &self,
19852        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19853            Ok(vec![EnvMutation {
19854                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19855                value: None,
19856            }])
19857        }
19858    }
19859
19860    fn agent(value: &str) -> AgentId {
19861        AgentId::new(value).unwrap()
19862    }
19863
19864    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19865        let unique = std::time::SystemTime::now()
19866            .duration_since(std::time::UNIX_EPOCH)
19867            .unwrap()
19868            .as_nanos();
19869        std::env::temp_dir().join(format!(
19870            "gate4agent-node-{label}-{}-{unique}",
19871            std::process::id(),
19872        ))
19873    }
19874
19875    fn materialization_test_shared(
19876        deny_secret: bool,
19877    ) -> (
19878        NodeShared,
19879        NativeRuntime,
19880        ResolvedEnvironmentProfileReceipt,
19881        PathBuf,
19882        Arc<AtomicBool>,
19883    ) {
19884        let workspace = WorkspaceConfig::new(
19885            WorkspaceId::new("primary").unwrap(),
19886            std::env::current_dir().unwrap(),
19887        )
19888        .unwrap();
19889        materialization_test_shared_with_workspace(deny_secret, workspace)
19890    }
19891
19892    fn materialization_test_shared_with_workspace(
19893        deny_secret: bool,
19894        workspace: WorkspaceConfig,
19895    ) -> (
19896        NodeShared,
19897        NativeRuntime,
19898        ResolvedEnvironmentProfileReceipt,
19899        PathBuf,
19900        Arc<AtomicBool>,
19901    ) {
19902        let root = temporary_workspace_root(if deny_secret {
19903            "materialization-denied"
19904        } else {
19905            "materialization-ready"
19906        });
19907        std::fs::create_dir_all(&root).unwrap();
19908        let materialization_root = root.join("session-environments");
19909        let deny = Arc::new(AtomicBool::new(deny_secret));
19910        let resolver = Arc::new(FixtureSecretResolver {
19911            deny: Arc::clone(&deny),
19912        });
19913        let materializer = SessionEnvironmentMaterializer::new(
19914            materialization_root,
19915            resolver,
19916        )
19917        .unwrap();
19918        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19919        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19920            "materialized-claude-r1",
19921        )
19922        .unwrap();
19923        let materialization = NodeSessionMaterializationProfile::new(
19924            vec![NodeSessionEnvironmentMutation::SetSecret {
19925                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19926                reference: NodeSecretReference::new("fixture-token").unwrap(),
19927            }],
19928            vec![NodeSessionPathBinding::new(
19929                "GATE4AGENT_PROVIDER_HOME",
19930                NodeSessionPathClass::ProviderHome,
19931            )
19932            .unwrap()],
19933            vec![NodeSessionFile::secret(
19934                NodeSessionPathClass::Config,
19935                "auth/token",
19936                NodeSecretReference::new("fixture-token").unwrap(),
19937            )
19938            .unwrap()],
19939        )
19940        .unwrap();
19941        let catalog = active_registry().unwrap();
19942        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19943        let control = runtime.native_launch_profile_control();
19944        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19945            profile_id.clone(),
19946            profile_revision.clone(),
19947            agent("claude"),
19948            [NativeLaunchProfile::new(
19949                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19950                agent("claude"),
19951                TransportKind::Pty,
19952                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19953                Arc::new(EmptyEnvironmentResolver),
19954            )
19955            .unwrap()],
19956            Some(materialization),
19957        )
19958        .unwrap();
19959        let (binding, native_profiles, materialization) = node_profile.into_parts();
19960        for native_profile in native_profiles {
19961            runtime.upsert_native_launch_profile(native_profile).unwrap();
19962        }
19963        let shared = NodeShared::new_with_incarnation(
19964            handle,
19965            "fixture-token".to_owned(),
19966            NodeId::new("node-materialization-test").unwrap(),
19967            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19968            vec![workspace],
19969            vec![agent("claude")],
19970            ProviderRuntimeStatuses::default(),
19971            None,
19972            Vec::new(),
19973            Vec::new(),
19974            SpawnProfileRegistry::default(),
19975            Some(control),
19976            None,
19977            Some(materializer),
19978            None,
19979            Vec::new(),
19980            Vec::new(),
19981            Vec::new(),
19982            Vec::new(),
19983            None,
19984            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19985        );
19986        shared
19987            .environment_profiles
19988            .write()
19989            .unwrap_or_else(|poisoned| poisoned.into_inner())
19990            .insert(binding.id.clone(), binding);
19991        shared
19992            .environment_materialization_profiles
19993            .write()
19994            .unwrap_or_else(|poisoned| poisoned.into_inner())
19995            .insert(profile_id.clone(), materialization.unwrap());
19996        (
19997            shared,
19998            runtime,
19999            ResolvedEnvironmentProfileReceipt {
20000                profile_id,
20001                profile_revision,
20002                network_allowlist: None,
20003                browser_profile_id: None,
20004            },
20005            root,
20006            deny,
20007        )
20008    }
20009
20010
20011    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20012
20013    async fn characterization_spawn(
20014        shared: &NodeShared,
20015        mode: SessionMode,
20016        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20017        required: &[ProviderRuntimeCapability],
20018        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20019    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20020        shared
20021            .spawn_session_with_deadline(
20022                WorkspaceId::new("primary").unwrap(),
20023                agent("claude"),
20024                mode,
20025                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20026                None,
20027                environment_profile,
20028                None,
20029                None,
20030                None,
20031                Some(ProviderRuntimePolicy::raw_pty()),
20032                SpawnRecordPolicy::ProviderIdentityOnly,
20033                Some(Instant::now() + Duration::from_secs(30)),
20034                required,
20035                harness_mcp,
20036                ApprovalLevel::default(),
20037                None,
20038                None,
20039            )
20040            .await
20041    }
20042
20043    fn assert_no_session_state(shared: &NodeShared) {
20044        assert!(shared.handle.snapshot().sessions.is_empty());
20045        assert!(shared
20046            .session_bindings
20047            .lock()
20048            .unwrap_or_else(|poisoned| poisoned.into_inner())
20049            .is_empty());
20050        assert!(shared
20051            .materializations
20052            .lock()
20053            .unwrap_or_else(|poisoned| poisoned.into_inner())
20054            .is_empty());
20055    }
20056
20057    #[tokio::test]
20058    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20059        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20060        let error = characterization_spawn(
20061            &shared,
20062            SessionMode::Pty,
20063            None,
20064            &[ProviderRuntimeCapability::SemanticReadiness],
20065            None,
20066        )
20067        .await
20068        .unwrap_err();
20069        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20070        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20071        assert_no_session_state(&shared);
20072        drop(shared);
20073        drop(runtime);
20074        std::fs::remove_dir_all(root).unwrap();
20075    }
20076
20077    #[tokio::test]
20078    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20079        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20080        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20081            .await
20082            .unwrap_err();
20083        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20084        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20085        assert_no_session_state(&shared);
20086        drop(shared);
20087        drop(runtime);
20088        std::fs::remove_dir_all(root).unwrap();
20089    }
20090
20091    #[test]
20092    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20093        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20094        let address = SessionAddress {
20095            workspace_id: WorkspaceId::new("primary").unwrap(),
20096            session: SessionKey {
20097                instance_id: AgentInstanceId(77),
20098                generation: SessionGeneration(1),
20099            },
20100        };
20101        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20102        let removed = shared.remove_binding(&address).expect("binding removed");
20103        assert!(removed.environment_profile.is_some());
20104        assert!(shared.remove_binding(&address).is_none());
20105        assert_no_session_state(&shared);
20106        drop(shared);
20107        drop(runtime);
20108        std::fs::remove_dir_all(root).unwrap();
20109    }
20110
20111    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20112        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20113        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20114        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20115            .expect("review current test executable as fixture helper");
20116        let registry = HarnessMcpProxyRegistry::new(helper);
20117        let spec = crate::protocol::SpawnSpec {
20118            target: crate::protocol::SpawnTarget {
20119                node_id: NodeId::new("node-characterization").unwrap(),
20120                workspace_id: WorkspaceId::new("primary").unwrap(),
20121                worktree_id: None,
20122            },
20123            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20124            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20125            overrides: crate::protocol::SpawnOverrides::default(),
20126            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20127            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20128            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20129        };
20130        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20131        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20132        let launch = HarnessMcpLaunchV1 {
20133            server_name: "fixture-harness".to_owned(),
20134            args: vec!["--session-proxy".to_owned()],
20135            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20136            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20137            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20138            trace: None,
20139            scrub_env: Vec::new(),
20140        };
20141        let expires = unix_time_ms().saturating_add(30_000);
20142        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20143        registry
20144            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20145            .unwrap()
20146    }
20147
20148    #[tokio::test]
20149    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20150        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20151        let prepared = characterization_prepared_harness("claude").await;
20152        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20153            .await
20154            .unwrap_err();
20155        // Inline is stopped by runtime admission before the harness check runs.
20156        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20157        let mismatched = characterization_prepared_harness("codex").await;
20158        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20159            .await
20160            .unwrap_err();
20161        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20162        assert_no_session_state(&shared);
20163        drop(shared);
20164        drop(runtime);
20165        std::fs::remove_dir_all(root).unwrap();
20166    }
20167
20168    #[tokio::test]
20169    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20170        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20171        shared.native_launch_profile_control = None;
20172        let prepared = characterization_prepared_harness("claude").await;
20173        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20174            .await
20175            .unwrap_err();
20176        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20177        assert_no_session_state(&shared);
20178        drop(shared);
20179        drop(runtime);
20180        std::fs::remove_dir_all(root).unwrap();
20181    }
20182}
20183
20184#[cfg(all(test, windows))]
20185mod tests {
20186    use super::*;
20187    use gate4agent_types::ProviderActivity;
20188    use crate::session_environment::{
20189        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20190        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20191        NodeSessionPathClass,
20192    };
20193    use gate4agent_catalog::EnvMutation;
20194    use gate4agent_runtime_native::{
20195        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20196        NativeLaunchProfile, NativeLaunchProfileId,
20197    };
20198    use std::ffi::OsString;
20199    use std::sync::Arc;
20200
20201    struct EmptyEnvironmentResolver;
20202
20203    struct FixtureSecretResolver {
20204        deny: Arc<AtomicBool>,
20205    }
20206
20207    impl NodeSecretResolver for FixtureSecretResolver {
20208        fn resolve(
20209            &self,
20210            _reference: &NodeSecretReference,
20211        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20212            if self.deny.load(Ordering::Acquire) {
20213                Err(NodeSecretResolveError::Denied)
20214            } else {
20215                NodeSecretValue::text("fixture-secret")
20216                    .map_err(|_| NodeSecretResolveError::Unavailable)
20217            }
20218        }
20219    }
20220
20221    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20222        fn resolve_child_environment(
20223            &self,
20224        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20225            Ok(vec![EnvMutation {
20226                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20227                value: None,
20228            }])
20229        }
20230    }
20231
20232    fn agent(value: &str) -> AgentId {
20233        AgentId::new(value).unwrap()
20234    }
20235
20236    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20237        let catalog = active_registry().unwrap();
20238        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20239        let workspace = WorkspaceConfig::new(
20240            WorkspaceId::new("primary").unwrap(),
20241            std::env::current_dir().unwrap(),
20242        )
20243        .unwrap()
20244        .with_worktree_service_mode(mode);
20245        NodeShared::new(
20246            handle,
20247            "fixture-token".to_owned(),
20248            NodeId::new("node-terminal-test").unwrap(),
20249            vec![workspace],
20250            vec![agent("claude")],
20251        )
20252    }
20253
20254    fn terminal_test_shared() -> NodeShared {
20255        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20256    }
20257
20258    #[cfg(feature = "fixture")]
20259    #[test]
20260    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20261        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20262        assert_eq!(probe.latest_code(), None);
20263
20264        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20265        assert_eq!(
20266            probe.latest_code(),
20267            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20268        );
20269
20270        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20271        assert_eq!(
20272            probe.latest_code(),
20273            Some(NodeFailureCode::SpawnDeadlineExceeded),
20274        );
20275
20276        probe.record(None);
20277        assert_eq!(probe.latest_code(), None);
20278    }
20279
20280    #[test]
20281    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20282        let source = temporary_workspace_root("launch-inventory-source");
20283        let allocation = temporary_workspace_root("launch-inventory-allocation");
20284        std::fs::create_dir_all(&source).unwrap();
20285        std::fs::create_dir_all(&allocation).unwrap();
20286        let workspace_id = WorkspaceId::new("primary").unwrap();
20287        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20288            .unwrap()
20289            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20290        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20291            workspace = workspace
20292                .with_managed_worktree_profile(
20293                    ManagedWorktreeProfile::new(
20294                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20295                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20296                        &allocation,
20297                        "gate4agent",
20298                        "HEAD",
20299                        ManagedWorktreeRetention::RemoveWhenReleased,
20300                    )
20301                    .unwrap(),
20302                )
20303                .unwrap();
20304        }
20305        let overflow = workspace.clone().with_managed_worktree_profile(
20306            ManagedWorktreeProfile::new(
20307                WorktreeProfileId::new("overflow").unwrap(),
20308                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20309                &allocation,
20310                "gate4agent",
20311                "HEAD",
20312                ManagedWorktreeRetention::Retain,
20313            )
20314            .unwrap(),
20315        );
20316        assert!(matches!(
20317            overflow,
20318            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20319                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20320                ..
20321            }),
20322        ));
20323
20324        let catalog = active_registry().unwrap();
20325        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20326        let shared = NodeShared::new(
20327            handle,
20328            "fixture-token".to_owned(),
20329            NodeId::new("node-launch-inventory").unwrap(),
20330            vec![workspace],
20331            vec![agent("claude")],
20332        );
20333        let snapshot = shared.snapshot();
20334        let launch = snapshot.launch_inventory.as_ref().unwrap();
20335        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20336        assert!(launch.bundles.as_ref().unwrap().is_empty());
20337        let profiles = snapshot.workspaces[0]
20338            .managed_worktree_profiles
20339            .as_ref()
20340            .unwrap();
20341        assert_eq!(
20342            snapshot.workspaces[0].worktree_service_mode,
20343            Some(WorktreeServiceMode::Managed),
20344        );
20345        assert_eq!(
20346            profiles.profiles.len(),
20347            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20348        );
20349        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20350        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20351            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20352        }
20353        let legacy = snapshot_for_wire(
20354            &shared,
20355            true,
20356            true,
20357            false,
20358            false,
20359            true,
20360            false,
20361            true,
20362            false,
20363            true,
20364        );
20365        assert!(legacy.launch_inventory.is_none());
20366        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20367
20368        drop(shared);
20369        std::fs::remove_dir_all(source).unwrap();
20370        std::fs::remove_dir_all(allocation).unwrap();
20371    }
20372
20373    #[test]
20374    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20375        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20376            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20377        }));
20378        let source = gate4agent_types::ProviderSource {
20379            family: AdapterFamily::PtySemantic,
20380            binding: AdapterBinding::new(
20381                gate4agent_types::AdapterId::new("claude").unwrap(),
20382                "fixture/v1",
20383                gate4agent_types::AdapterVerification::SyntheticFixture,
20384            )
20385            .unwrap(),
20386        };
20387        let provider = ProviderSnapshot {
20388            sequence: 17,
20389            session: Some(ProviderSessionIdentity {
20390                key: ProviderSessionKey::SessionId,
20391                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20392                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20393            }),
20394            model: Some("PRIVATE_MODEL".to_owned()),
20395            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20396            completed_turns: 3,
20397            usage: gate4agent_types::TokenUsage {
20398                input_tokens: 10,
20399                output_tokens: 20,
20400                cache_read_tokens: 30,
20401                cache_write_tokens: 40,
20402                reasoning_tokens: 50,
20403                context_window: Some(200_000),
20404            },
20405            lead_activity: ProviderActivity::WaitingForInput,
20406            activity: ProviderActivity::WaitingForInput,
20407            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20408            active_tools: (0..9)
20409                .map(|index| gate4agent_types::ActiveProviderTool {
20410                    id: format!("private-tool-id-{index}"),
20411                    name: if index == 0 {
20412                        format!("  Read\u{0000}{}", "x".repeat(80))
20413                    } else {
20414                        format!("Tool-{index}")
20415                    },
20416                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20417                })
20418                .collect(),
20419            interactions: vec![gate4agent_types::ProviderInteraction {
20420                id: gate4agent_types::ProviderInteractionId(17),
20421                source: source.clone(),
20422                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20423                interaction_kind: ProviderInteractionKind::Approval,
20424                tool_name: " Approve\u{0007}Tool ".to_owned(),
20425                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20426                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20427                resume_lead_activity: Some(ProviderActivity::Working),
20428                status: ProviderInteractionStatus::Pending,
20429            }],
20430            subagents: vec![gate4agent_types::ProviderSubagent {
20431                source: source.clone(),
20432                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20433                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20434                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20435            }],
20436            sources: vec![gate4agent_types::ProviderSourceCursor {
20437                source,
20438                sequence: 17,
20439                gap_count: 2,
20440                stale: true,
20441            }],
20442            last_event: Some(ProviderEvent::Error {
20443                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20444            }),
20445            gap_count: 2,
20446            stale: true,
20447        };
20448        let address = terminal_address(3);
20449        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20450        assert_eq!(entry.address, address);
20451        assert_eq!(entry.progress.provider_sequence, 17);
20452        assert_eq!(entry.progress.active_tool_count, 9);
20453        assert_eq!(
20454            entry.progress.active_tool_labels.len(),
20455            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20456        );
20457        assert_eq!(entry.progress.subagent_count, 1);
20458        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20459        assert!(entry.progress.truncated);
20460        let encoded = serde_json::to_string(&entry).unwrap();
20461        for forbidden in [
20462            "PRIVATE_PROVIDER_SESSION",
20463            "PRIVATE_PROVIDER_PATH",
20464            "PRIVATE_MODEL",
20465            "PRIVATE_TOOL_INVENTORY",
20466            "PRIVATE_CURRENT_PROMPT",
20467            "PRIVATE_TOOL_INPUT_JSON",
20468            "PRIVATE_PROVIDER_REQUEST",
20469            "PRIVATE_INTERACTION_PROMPT",
20470            "PRIVATE_INTERACTION_AGENT",
20471            "PRIVATE_SUBAGENT_ID",
20472            "PRIVATE_SUBAGENT_TYPE",
20473            "PRIVATE_SUBAGENT_DESCRIPTION",
20474            "PRIVATE_PROVIDER_ERROR",
20475            "input_json",
20476            "current_prompt",
20477            "session_id",
20478        ] {
20479            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20480        }
20481
20482        let snapshot = NodeSnapshot {
20483            node_id: NodeId::new("node-progress-test").unwrap(),
20484            enabled_providers: vec![agent("claude")],
20485            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20486            workspaces: Vec::new(),
20487            session_records: Vec::new(),
20488            managed_worktrees: Vec::new(),
20489            launch_inventory: None,
20490            agent_progress: vec![entry],
20491        };
20492        let mut legacy_reply = ResponseEnvelope {
20493            request_id: 1,
20494            result: Ok(NodeResponse::Snapshot {
20495                event_sequence: 0,
20496                controller: None,
20497                snapshot: snapshot.clone(),
20498            }),
20499        };
20500        project_response_without_agent_progress(&mut legacy_reply);
20501        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20502            panic!("agent progress projection changed response kind");
20503        };
20504        assert!(legacy.agent_progress.is_empty());
20505        assert_eq!(snapshot.agent_progress.len(), 1);
20506    }
20507
20508    fn terminal_address(generation: u64) -> SessionAddress {
20509        SessionAddress {
20510            workspace_id: WorkspaceId::new("primary").unwrap(),
20511            session: SessionKey {
20512                instance_id: AgentInstanceId(41),
20513                generation: SessionGeneration(generation),
20514            },
20515        }
20516    }
20517
20518    fn spawn_spec_fixture() -> SpawnSpec {
20519        SpawnSpec {
20520            target: crate::protocol::SpawnTarget {
20521                node_id: NodeId::new("node-terminal-test").unwrap(),
20522                workspace_id: WorkspaceId::new("primary").unwrap(),
20523                worktree_id: None,
20524            },
20525            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20526            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20527                .unwrap(),
20528            overrides: crate::protocol::SpawnOverrides::default(),
20529            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20530            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20531            required_capabilities: SpawnRequiredCapabilities::default(),
20532        }
20533    }
20534
20535    fn environment_profile_test_shared(
20536    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20537        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20538        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20539            "local-claude-r1",
20540        )
20541        .unwrap();
20542        let spawn_profiles = SpawnProfileRegistry::new([
20543            crate::protocol::SpawnProfileDefaults {
20544                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20545                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20546                provider: agent("claude"),
20547                mode: SessionMode::Pty,
20548                terminal_size: gate4agent_types::TerminalSize {
20549                    rows: 24,
20550                    columns: 80,
20551                },
20552                prompt: None,
20553                bundle_id: None,
20554                context_id: None,
20555                environment_profile_id: Some(profile_id.clone()),
20556            },
20557        ])
20558        .unwrap();
20559        let catalog = active_registry().unwrap();
20560        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20561        let control = runtime.native_launch_profile_control();
20562        let node_profile = NodeEnvironmentProfile::new(
20563            profile_id.clone(),
20564            profile_revision.clone(),
20565            agent("claude"),
20566            [NativeLaunchProfile::new(
20567                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20568                agent("claude"),
20569                TransportKind::Pty,
20570                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20571                Arc::new(EmptyEnvironmentResolver),
20572            )
20573            .unwrap()],
20574        )
20575        .unwrap();
20576        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20577        for native_profile in native_profiles {
20578            runtime.upsert_native_launch_profile(native_profile).unwrap();
20579        }
20580        let workspace = WorkspaceConfig::new(
20581            WorkspaceId::new("primary").unwrap(),
20582            std::env::current_dir().unwrap(),
20583        )
20584        .unwrap();
20585        let shared = NodeShared::new_with_incarnation(
20586            handle,
20587            "fixture-token".to_owned(),
20588            NodeId::new("node-terminal-test").unwrap(),
20589            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20590            vec![workspace],
20591            vec![agent("claude")],
20592            ProviderRuntimeStatuses::default(),
20593            None,
20594            Vec::new(),
20595            Vec::new(),
20596            spawn_profiles,
20597            Some(control),
20598            None,
20599            None,
20600            None,
20601            Vec::new(),
20602            Vec::new(),
20603            Vec::new(),
20604            Vec::new(),
20605            None,
20606            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20607        );
20608        shared
20609            .environment_profiles
20610            .write()
20611            .unwrap_or_else(|poisoned| poisoned.into_inner())
20612            .insert(binding.id.clone(), binding);
20613        (
20614            shared,
20615            runtime,
20616            ResolvedEnvironmentProfileReceipt {
20617                profile_id,
20618                profile_revision,
20619                network_allowlist: None,
20620                browser_profile_id: None,
20621            },
20622        )
20623    }
20624
20625    fn materialization_test_shared(
20626        deny_secret: bool,
20627    ) -> (
20628        NodeShared,
20629        NativeRuntime,
20630        ResolvedEnvironmentProfileReceipt,
20631        PathBuf,
20632        Arc<AtomicBool>,
20633    ) {
20634        let workspace = WorkspaceConfig::new(
20635            WorkspaceId::new("primary").unwrap(),
20636            std::env::current_dir().unwrap(),
20637        )
20638        .unwrap();
20639        materialization_test_shared_with_workspace(deny_secret, workspace)
20640    }
20641
20642    fn materialization_test_shared_with_workspace(
20643        deny_secret: bool,
20644        workspace: WorkspaceConfig,
20645    ) -> (
20646        NodeShared,
20647        NativeRuntime,
20648        ResolvedEnvironmentProfileReceipt,
20649        PathBuf,
20650        Arc<AtomicBool>,
20651    ) {
20652        let root = temporary_workspace_root(if deny_secret {
20653            "materialization-denied"
20654        } else {
20655            "materialization-ready"
20656        });
20657        std::fs::create_dir_all(&root).unwrap();
20658        let materialization_root = root.join("session-environments");
20659        let deny = Arc::new(AtomicBool::new(deny_secret));
20660        let resolver = Arc::new(FixtureSecretResolver {
20661            deny: Arc::clone(&deny),
20662        });
20663        let materializer = SessionEnvironmentMaterializer::new(
20664            materialization_root,
20665            resolver,
20666        )
20667        .unwrap();
20668        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20669        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20670            "materialized-claude-r1",
20671        )
20672        .unwrap();
20673        let materialization = NodeSessionMaterializationProfile::new(
20674            vec![NodeSessionEnvironmentMutation::SetSecret {
20675                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20676                reference: NodeSecretReference::new("fixture-token").unwrap(),
20677            }],
20678            vec![NodeSessionPathBinding::new(
20679                "GATE4AGENT_PROVIDER_HOME",
20680                NodeSessionPathClass::ProviderHome,
20681            )
20682            .unwrap()],
20683            vec![NodeSessionFile::secret(
20684                NodeSessionPathClass::Config,
20685                "auth/token",
20686                NodeSecretReference::new("fixture-token").unwrap(),
20687            )
20688            .unwrap()],
20689        )
20690        .unwrap();
20691        let catalog = active_registry().unwrap();
20692        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20693        let control = runtime.native_launch_profile_control();
20694        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20695            profile_id.clone(),
20696            profile_revision.clone(),
20697            agent("claude"),
20698            [NativeLaunchProfile::new(
20699                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20700                agent("claude"),
20701                TransportKind::Pty,
20702                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20703                Arc::new(EmptyEnvironmentResolver),
20704            )
20705            .unwrap()],
20706            Some(materialization),
20707        )
20708        .unwrap();
20709        let (binding, native_profiles, materialization) = node_profile.into_parts();
20710        for native_profile in native_profiles {
20711            runtime.upsert_native_launch_profile(native_profile).unwrap();
20712        }
20713        let shared = NodeShared::new_with_incarnation(
20714            handle,
20715            "fixture-token".to_owned(),
20716            NodeId::new("node-materialization-test").unwrap(),
20717            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20718            vec![workspace],
20719            vec![agent("claude")],
20720            ProviderRuntimeStatuses::default(),
20721            None,
20722            Vec::new(),
20723            Vec::new(),
20724            SpawnProfileRegistry::default(),
20725            Some(control),
20726            None,
20727            Some(materializer),
20728            None,
20729            Vec::new(),
20730            Vec::new(),
20731            Vec::new(),
20732            Vec::new(),
20733            None,
20734            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20735        );
20736        shared
20737            .environment_profiles
20738            .write()
20739            .unwrap_or_else(|poisoned| poisoned.into_inner())
20740            .insert(binding.id.clone(), binding);
20741        shared
20742            .environment_materialization_profiles
20743            .write()
20744            .unwrap_or_else(|poisoned| poisoned.into_inner())
20745            .insert(profile_id.clone(), materialization.unwrap());
20746        (
20747            shared,
20748            runtime,
20749            ResolvedEnvironmentProfileReceipt {
20750                profile_id,
20751                profile_revision,
20752                network_allowlist: None,
20753                browser_profile_id: None,
20754            },
20755            root,
20756            deny,
20757        )
20758    }
20759
20760    #[cfg(feature = "fixture")]
20761    #[derive(Clone, Copy)]
20762    enum CodexHomeProfile {
20763        Missing,
20764        WrongClass,
20765        Exact,
20766    }
20767
20768    #[cfg(feature = "fixture")]
20769    fn codex_bundle_test_shared(
20770        home_profile: CodexHomeProfile,
20771    ) -> (
20772        NodeShared,
20773        NativeRuntime,
20774        ResolvedEnvironmentProfileReceipt,
20775        NodeBundle,
20776        PathBuf,
20777    ) {
20778        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20779        const CLAUDE_MANIFEST: &[u8] =
20780            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20781        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20782        const DIGEST: &str =
20783            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20784
20785        let (shared, mut runtime, _claude_receipt, root, _deny) =
20786            materialization_test_shared(false);
20787        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20788            CodexHomeProfile::Missing => "codex-home-missing",
20789            CodexHomeProfile::WrongClass => "codex-home-wrong",
20790            CodexHomeProfile::Exact => "codex-home-exact",
20791        })
20792        .unwrap();
20793        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20794            "codex-home-r1",
20795        )
20796        .unwrap();
20797        let materialization = match home_profile {
20798            CodexHomeProfile::Missing => None,
20799            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20800                Some(NodeSessionMaterializationProfile::new(
20801                    Vec::new(),
20802                    vec![NodeSessionPathBinding::new(
20803                        "CODEX_HOME",
20804                        if matches!(home_profile, CodexHomeProfile::Exact) {
20805                            NodeSessionPathClass::ProviderHome
20806                        } else {
20807                            NodeSessionPathClass::Config
20808                        },
20809                    )
20810                    .unwrap()],
20811                    Vec::new(),
20812                )
20813                .unwrap())
20814            }
20815        };
20816        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20817            profile_id.clone(),
20818            profile_revision.clone(),
20819            agent("codex"),
20820            [NativeLaunchProfile::new(
20821                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20822                agent("codex"),
20823                TransportKind::Pty,
20824                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20825                Arc::new(EmptyEnvironmentResolver),
20826            )
20827            .unwrap()],
20828            materialization,
20829        )
20830        .unwrap();
20831        let (binding, native_profiles, materialization) = node_profile.into_parts();
20832        for native_profile in native_profiles {
20833            runtime.upsert_native_launch_profile(native_profile).unwrap();
20834        }
20835        shared
20836            .environment_profiles
20837            .write()
20838            .unwrap_or_else(|poisoned| poisoned.into_inner())
20839            .insert(binding.id.clone(), binding);
20840        if let Some(materialization) = materialization {
20841            shared
20842                .environment_materialization_profiles
20843                .write()
20844                .unwrap_or_else(|poisoned| poisoned.into_inner())
20845                .insert(profile_id.clone(), materialization);
20846        }
20847
20848        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20849        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20850        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20851        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20852        std::fs::write(
20853            bundle_source.join(".claude-plugin/plugin.json"),
20854            CLAUDE_MANIFEST,
20855        )
20856        .unwrap();
20857        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20858        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20859        let bundle = NodeBundle::new(
20860            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20861            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20862            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20863            &bundle_source,
20864        )
20865        .unwrap();
20866        *shared
20867            .bundle_catalog
20868            .write()
20869            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20870            BundleCatalog::new([bundle.clone()]).unwrap();
20871        (
20872            shared,
20873            runtime,
20874            ResolvedEnvironmentProfileReceipt {
20875                profile_id,
20876                profile_revision,
20877                network_allowlist: None,
20878                browser_profile_id: None,
20879            },
20880            bundle,
20881            root,
20882        )
20883    }
20884
20885    #[cfg(feature = "fixture")]
20886    #[test]
20887    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20888        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20889            let (shared, runtime, receipt, bundle, root) =
20890                codex_bundle_test_shared(home_profile);
20891            let error = shared
20892                .bundle_layout(
20893                    &agent("codex"),
20894                    SessionMode::Pty,
20895                    Some(&receipt),
20896                    &bundle,
20897                )
20898                .unwrap_err();
20899            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20900            assert!(shared
20901                .materializations
20902                .lock()
20903                .unwrap_or_else(|poisoned| poisoned.into_inner())
20904                .is_empty());
20905            let materialization_root = root.join("session-environments");
20906            assert!(!std::fs::read_dir(&materialization_root)
20907                .unwrap()
20908                .filter_map(Result::ok)
20909                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20910            drop(shared);
20911            drop(runtime);
20912            let _ = std::fs::remove_dir_all(root);
20913        }
20914    }
20915
20916    #[cfg(feature = "fixture")]
20917    #[test]
20918    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20919        let (shared, runtime, receipt, bundle, root) =
20920            codex_bundle_test_shared(CodexHomeProfile::Exact);
20921        let address = SessionAddress {
20922            workspace_id: WorkspaceId::new("primary").unwrap(),
20923            session: SessionKey {
20924                instance_id: AgentInstanceId(310),
20925                generation: SessionGeneration(1),
20926            },
20927        };
20928        let (overlay, guard) = shared
20929            .prepare_session_materialization(
20930                &address,
20931                &agent("codex"),
20932                SessionMode::Pty,
20933                Some(&receipt),
20934                Some(&bundle.receipt()),
20935                None,
20936                None,
20937            )
20938            .unwrap()
20939            .unwrap();
20940        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20941        let ownership = shared
20942            .materializations
20943            .lock()
20944            .unwrap_or_else(|poisoned| poisoned.into_inner())
20945            .get(guard.id().unwrap())
20946            .unwrap()
20947            .clone();
20948        let (profile, _, _) = shared
20949            .materialization_profile(
20950                &agent("codex"),
20951                SessionMode::Pty,
20952                Some(&receipt),
20953                Some(&bundle.receipt()),
20954                None,
20955            )
20956            .unwrap()
20957            .unwrap();
20958        let environment = shared
20959            .session_environment_materializer
20960            .as_ref()
20961            .unwrap()
20962            .resolve_environment(&ownership, &profile)
20963            .unwrap();
20964        assert!(environment.iter().any(|mutation| {
20965            mutation.key == OsString::from("CODEX_HOME")
20966                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20967        }));
20968        assert_eq!(
20969            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20970                .unwrap(),
20971            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20972        );
20973        assert!(std::fs::read_dir(ownership.bundle_root())
20974            .unwrap()
20975            .next()
20976            .is_none());
20977        drop(guard);
20978        drop(shared);
20979        drop(runtime);
20980        let _ = std::fs::remove_dir_all(root);
20981    }
20982
20983    #[cfg(feature = "fixture")]
20984    #[test]
20985    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20986        let (shared, runtime, receipt, bundle, root) =
20987            codex_bundle_test_shared(CodexHomeProfile::Exact);
20988        let bundle_receipt = bundle.receipt();
20989        let address = SessionAddress {
20990            workspace_id: WorkspaceId::new("primary").unwrap(),
20991            session: SessionKey {
20992                instance_id: AgentInstanceId(311),
20993                generation: SessionGeneration(1),
20994            },
20995        };
20996        let (_overlay, guard) = shared
20997            .prepare_session_materialization(
20998                &address,
20999                &agent("codex"),
21000                SessionMode::Pty,
21001                Some(&receipt),
21002                Some(&bundle_receipt),
21003                None,
21004                None,
21005            )
21006            .unwrap()
21007            .unwrap();
21008        let materialization_id = guard.id().unwrap().clone();
21009        let skill_path = shared
21010            .materializations
21011            .lock()
21012            .unwrap_or_else(|poisoned| poisoned.into_inner())
21013            .get(&materialization_id)
21014            .unwrap()
21015            .provider_home()
21016            .join("skills/review-code/SKILL.md");
21017        let record_id = shared
21018            .bind_spawn_session_with_materialization(
21019                &address,
21020                agent("codex"),
21021                SessionMode::Pty,
21022                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21023                SpawnRecordPolicy::ProviderIdentityOnly,
21024                Some(receipt.clone()),
21025                Some(bundle_receipt.clone()),
21026                None,
21027                Some(materialization_id.clone()),
21028            )
21029            .unwrap()
21030            .unwrap();
21031        guard.retain();
21032        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21033
21034        let error = match shared.resolve_record_materialization(
21035                &record_id,
21036                &agent("codex"),
21037                SessionMode::Pty,
21038                Some(&receipt),
21039                Some(&bundle_receipt),
21040                None,
21041            ) {
21042                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21043                Err(error) => error,
21044            };
21045        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21046        assert_eq!(
21047            shared
21048                .materializations
21049                .lock()
21050                .unwrap_or_else(|poisoned| poisoned.into_inner())
21051                .get(&materialization_id)
21052                .unwrap()
21053                .state(),
21054            MaterializationState::RecoveryRequired,
21055        );
21056        drop(shared);
21057        drop(runtime);
21058        let _ = std::fs::remove_dir_all(root);
21059    }
21060
21061    fn bind_record_owned_materialization_fixture(
21062        shared: &NodeShared,
21063        receipt: &ResolvedEnvironmentProfileReceipt,
21064        instance_id: u64,
21065    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21066        let address = SessionAddress {
21067            workspace_id: WorkspaceId::new("primary").unwrap(),
21068            session: SessionKey {
21069                instance_id: AgentInstanceId(instance_id),
21070                generation: SessionGeneration(1),
21071            },
21072        };
21073        let (overlay, materialization_guard) = shared
21074            .prepare_session_materialization(
21075                &address,
21076                &agent("claude"),
21077                SessionMode::Pty,
21078                Some(receipt),
21079                None,
21080                None,
21081                None,
21082            )
21083            .unwrap()
21084            .unwrap();
21085        let materialization_id = materialization_guard.id().unwrap().clone();
21086        let materialization_root = shared
21087            .materializations
21088            .lock()
21089            .unwrap_or_else(|poisoned| poisoned.into_inner())
21090            .get(&materialization_id)
21091            .unwrap()
21092            .root()
21093            .to_path_buf();
21094        let selection = shared
21095            .select_environment_profile(
21096                address.session.instance_id,
21097                &agent("claude"),
21098                SessionMode::Pty,
21099                Some(receipt),
21100            )
21101            .unwrap()
21102            .unwrap();
21103        assert!(shared
21104            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21105            .unwrap()
21106            .is_none());
21107        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21108        let record_id = shared
21109            .bind_spawn_session_with_materialization(
21110                &address,
21111                agent("claude"),
21112                SessionMode::Pty,
21113                policy,
21114                SpawnRecordPolicy::ProviderIdentityOnly,
21115                Some(receipt.clone()),
21116                None,
21117                None,
21118                Some(materialization_id.clone()),
21119            )
21120            .unwrap()
21121            .unwrap();
21122        selection.retain();
21123        materialization_guard.retain();
21124        (address, record_id, materialization_id, materialization_root)
21125    }
21126
21127    #[test]
21128    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21129        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21130        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21131            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21132        }));
21133        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21134        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21135        let mut spec = spawn_spec_fixture();
21136        spec.expected_profile_revision =
21137            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21138        assert!(matches!(
21139            &spec.overrides.environment_profile_id,
21140            crate::protocol::SpawnOverride::Inherit,
21141        ));
21142        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21143            .requires_child_environment_profile_capability());
21144        assert!(request_requires_child_environment_profile(
21145            &shared,
21146            &NodeRequest::SpawnSpec { spec: spec.clone() },
21147        ));
21148
21149        let address = terminal_address(1);
21150        shared.bind_session_with_policy(
21151            &address,
21152            ProviderRuntimePolicy::raw_pty(),
21153            Some(environment_profile.clone()),
21154        );
21155        assert!(request_requires_child_environment_profile(
21156            &shared,
21157            &NodeRequest::Input {
21158                session: address.clone(),
21159                text: "x".to_owned(),
21160            },
21161        ));
21162
21163        let mut record = record("claude", "profile-record");
21164        record.active_session = Some(address.clone());
21165        record.environment_profile = Some(environment_profile.clone());
21166        let record_id = record.record_id.clone();
21167        shared.insert_record(record.clone()).unwrap();
21168        assert!(request_requires_child_environment_profile(
21169            &shared,
21170            &NodeRequest::ResumeSessionRecord {
21171                record_id,
21172                terminal_size: gate4agent_types::TerminalSize {
21173                    rows: 24,
21174                    columns: 80,
21175                },
21176                initial_prompt: None,
21177            },
21178        ));
21179
21180        let snapshot = snapshot_for_wire(
21181            &shared, false, true, true, true, false, false, false, false, true,
21182        );
21183        assert_eq!(snapshot.session_records.len(), 1);
21184        assert!(snapshot.session_records[0].environment_profile.is_none());
21185        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21186            sequence: 1,
21187            event: NodeEvent::SessionRecordUpserted { record },
21188        });
21189        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21190            panic!("record projection changed the event kind");
21191        };
21192        assert!(record.environment_profile.is_none());
21193
21194        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21195        let mut reply = ResponseEnvelope {
21196            request_id: 1,
21197            result: Ok(NodeResponse::SpawnSpecAccepted {
21198                receipt: resolved.receipt_with_environment(
21199                    shared.incarnation_id,
21200                    address,
21201                    Some(environment_profile),
21202                ),
21203            }),
21204        };
21205        project_response_without_child_environment_profile(&mut reply);
21206        assert_eq!(
21207            reply.result.unwrap_err().code,
21208            NodeFailureCode::UnsupportedCapability,
21209        );
21210    }
21211
21212    #[test]
21213    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21214        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21215        let snapshot = shared.snapshot();
21216        let profiles = snapshot
21217            .launch_inventory
21218            .as_ref()
21219            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21220            .unwrap();
21221        assert_eq!(profiles.len(), 1);
21222        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21223        assert!(snapshot.requires_child_environment_profile_capability());
21224
21225        let mut legacy = snapshot.clone();
21226        clear_snapshot_child_environment_profiles(&mut legacy);
21227        assert!(legacy
21228            .launch_inventory
21229            .as_ref()
21230            .unwrap()
21231            .spawn_profiles
21232            .as_ref()
21233            .unwrap()[0]
21234            .environment_profile
21235            .is_none());
21236        assert!(!legacy.requires_child_environment_profile_capability());
21237
21238        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21239        let bindings = shared
21240            .environment_profiles
21241            .read()
21242            .unwrap_or_else(|poisoned| poisoned.into_inner())
21243            .clone();
21244        let mut provider_mismatch = bindings.clone();
21245        provider_mismatch
21246            .values_mut()
21247            .next()
21248            .unwrap()
21249            .provider = agent("codex");
21250        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21251        let mut mode_mismatch = profile;
21252        mode_mismatch.mode = SessionMode::Inline;
21253        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21254
21255        shared
21256            .environment_profiles
21257            .write()
21258            .unwrap_or_else(|poisoned| poisoned.into_inner())
21259            .clear();
21260        assert!(shared
21261            .snapshot()
21262            .launch_inventory
21263            .unwrap()
21264            .spawn_profiles
21265            .unwrap()
21266            .is_empty());
21267    }
21268
21269    #[test]
21270    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21271        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21272            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21273        }));
21274        let shared = terminal_test_shared();
21275        let bundle = ResolvedBundleReceipt {
21276            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21277            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21278            digest: crate::protocol::SpawnBundleDigest::new(format!(
21279                "sha256:{}",
21280                "0".repeat(64),
21281            ))
21282            .unwrap(),
21283        };
21284        let mut bundled = record("claude", "bundle-record");
21285        bundled.bundle = Some(bundle.clone());
21286        let record_id = bundled.record_id.clone();
21287        shared.insert_record(bundled.clone()).unwrap();
21288        assert!(request_requires_session_bundle(
21289            &shared,
21290            &NodeRequest::ForgetSessionRecord {
21291                record_id: record_id.clone(),
21292            },
21293        ));
21294
21295        let snapshot = snapshot_for_wire(
21296            &shared, false, true, true, true, true, false, false, false, true,
21297        );
21298        assert!(snapshot.session_records[0].bundle.is_none());
21299        let launch = snapshot.launch_inventory.as_ref().unwrap();
21300        assert!(launch.spawn_profiles.is_some());
21301        assert!(launch.bundles.is_none());
21302        let event = project_event_without_session_bundle(NodeEventEnvelope {
21303            sequence: 1,
21304            event: NodeEvent::SessionRecordUpserted { record: bundled },
21305        });
21306        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21307            panic!("record projection changed the event kind");
21308        };
21309        assert!(record.bundle.is_none());
21310
21311        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21312        let mut reply = ResponseEnvelope {
21313            request_id: 1,
21314            result: Ok(NodeResponse::SpawnSpecAccepted {
21315                receipt: resolved.receipt_with_materialization(
21316                    shared.incarnation_id,
21317                    terminal_address(17),
21318                    None,
21319                    Some(bundle),
21320                    None,
21321                ),
21322            }),
21323        };
21324        project_response_without_session_bundle(&mut reply);
21325        assert_eq!(
21326            reply.result.unwrap_err().code,
21327            NodeFailureCode::UnsupportedCapability,
21328        );
21329    }
21330
21331    #[test]
21332    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21333        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21334            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21335        }));
21336        let context_id = SpawnContextId::new("context-review").unwrap();
21337        let context = ResolvedContextPackReceipt {
21338            id: context_id.clone(),
21339            digest: crate::protocol::SpawnContextDigest::new(format!(
21340                "sha256:{}",
21341                "c".repeat(64),
21342            ))
21343            .unwrap(),
21344            lineage: ContextPackLineageReceipt {
21345                source_node_id: NodeId::new("node-source").unwrap(),
21346                source_session: terminal_address(1),
21347                source_provider: agent("kimi"),
21348            },
21349            source_message_count: 2,
21350            retained_message_count: 2,
21351            byte_len: 256,
21352            truncated: false,
21353        };
21354        assert!(context.is_valid());
21355        let mut bound_record = record("codex", "context-record");
21356        bound_record.context_id = Some(context_id);
21357        bound_record.context = Some(context.clone());
21358        let session = gate4agent_types::SessionSnapshot {
21359            instance_id: AgentInstanceId(91),
21360            agent_id: agent("kimi"),
21361            transport: TransportKind::Pty,
21362            generation: SessionGeneration(3),
21363            status: SessionStatus::Running,
21364            pending_operation: None,
21365            pending_input: None,
21366            process_id: Some(9001),
21367            terminal_size: None,
21368            terminal_frame: None,
21369            terminal_stale: None,
21370            session_options: None,
21371            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21372            history: gate4agent_types::HistorySnapshot {
21373                pending: None,
21374                candidates: vec![HistoryCandidateSummary {
21375                    id: "candidate-opaque".to_owned(),
21376                    session_id_hint: "session-hint".to_owned(),
21377                    modified_at_unix_ms: Some(1),
21378                }],
21379                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21380                loaded: Some(HistorySessionRecord {
21381                    session_id: "private-vendor-session".to_owned(),
21382                    title: Some("private title".to_owned()),
21383                    cwd: Some(r"C:\private\history-root".to_owned()),
21384                    model: Some("private-model".to_owned()),
21385                    message_count: 2,
21386                    completed_turn_count: None,
21387                    total_tokens: 19,
21388                    messages: vec![
21389                        gate4agent_types::HistoryMessageRecord {
21390                            role: gate4agent_types::HistoryMessageRole::User,
21391                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21392                        },
21393                        gate4agent_types::HistoryMessageRecord {
21394                            role: gate4agent_types::HistoryMessageRole::Assistant,
21395                            text: "private answer".to_owned(),
21396                        },
21397                    ],
21398                }),
21399                last_error: None,
21400            },
21401            resume: gate4agent_types::ResumeSnapshot::default(),
21402            foreground: gate4agent_types::ForegroundSnapshot::default(),
21403            provider: gate4agent_types::ProviderSnapshot::default(),
21404            screen_state: gate4agent_types::PtyScreenState::default(),
21405        };
21406        let original = NodeSnapshot {
21407            node_id: NodeId::new("node-terminal-test").unwrap(),
21408            enabled_providers: vec![agent("kimi"), agent("codex")],
21409            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21410            workspaces: vec![WorkspaceSnapshot {
21411                workspace_id: WorkspaceId::new("primary").unwrap(),
21412                canonical_root: opaque_windows_path(
21413                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21414                ),
21415                sessions: vec![session],
21416                worktree_service_mode: None,
21417                managed_worktree_profiles: None,
21418            }],
21419            session_records: vec![bound_record],
21420            managed_worktrees: Vec::new(),
21421            launch_inventory: None,
21422            agent_progress: Vec::new(),
21423        };
21424
21425        let mut negotiated = original.clone();
21426        project_snapshot_history_for_wire(&mut negotiated, true);
21427        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21428        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21429        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21430        assert!(!serde_json::to_string(&negotiated)
21431            .unwrap()
21432            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21433
21434        let mut legacy = original;
21435        project_snapshot_history_for_wire(&mut legacy, false);
21436        clear_snapshot_context_packs(&mut legacy);
21437        assert_eq!(
21438            legacy.workspaces[0].sessions[0].history,
21439            gate4agent_types::HistorySnapshot::default(),
21440        );
21441        assert!(legacy.session_records[0].context_id.is_none());
21442        assert!(legacy.session_records[0].context.is_none());
21443        let encoded = serde_json::to_string(&legacy).unwrap();
21444        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21445        assert!(!encoded.contains("context-review"));
21446
21447        let mut reply = ResponseEnvelope {
21448            request_id: 7,
21449            result: Ok(NodeResponse::ContextPackExported { context }),
21450        };
21451        project_response_without_context_pack(&mut reply);
21452        assert_eq!(
21453            reply.result.unwrap_err().code,
21454            NodeFailureCode::UnsupportedCapability,
21455        );
21456    }
21457
21458    #[test]
21459    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21460        let shared = terminal_test_shared();
21461        let history = HistorySessionRecord {
21462            session_id: "fixture-history".to_owned(),
21463            title: None,
21464            cwd: None,
21465            model: None,
21466            message_count: 1,
21467            completed_turn_count: None,
21468            total_tokens: 0,
21469            messages: vec![gate4agent_types::HistoryMessageRecord {
21470                role: gate4agent_types::HistoryMessageRole::User,
21471                text: "bounded handoff".to_owned(),
21472            }],
21473        };
21474        let pack = NodeContextPack::export(
21475            ContextPackLineageReceipt {
21476                source_node_id: shared.node_id.clone(),
21477                source_session: terminal_address(1),
21478                source_provider: agent("claude"),
21479            },
21480            &history,
21481        )
21482        .unwrap();
21483        let context = shared
21484            .context_catalog
21485            .write()
21486            .unwrap_or_else(|poisoned| poisoned.into_inner())
21487            .insert(pack)
21488            .unwrap();
21489        let mut retained = record("codex", "context-retained-record");
21490        retained.context_id = Some(context.id.clone());
21491        retained.context = Some(context.clone());
21492        let record_id = retained.record_id.clone();
21493        shared.insert_record(retained).unwrap();
21494        assert_eq!(
21495            shared.forget_context_pack(&context.id).unwrap_err().code,
21496            NodeFailureCode::ContextPackBusy,
21497        );
21498        assert!(shared
21499            .context_catalog
21500            .read()
21501            .unwrap_or_else(|poisoned| poisoned.into_inner())
21502            .get(&context.id)
21503            .is_some());
21504        shared
21505            .session_records
21506            .lock()
21507            .unwrap_or_else(|poisoned| poisoned.into_inner())
21508            .records
21509            .remove(&record_id);
21510
21511        let mut spec = spawn_spec_fixture();
21512        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21513            value: context.id.clone(),
21514        };
21515        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21516        let receipt = resolved.receipt_with_materialization(
21517            shared.incarnation_id,
21518            terminal_address(2),
21519            None,
21520            None,
21521            Some(context.clone()),
21522        );
21523        assert!(receipt.context_binding_is_valid());
21524        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21525
21526        shared.forget_context_pack(&context.id).unwrap();
21527        assert!(shared
21528            .context_catalog
21529            .read()
21530            .unwrap_or_else(|poisoned| poisoned.into_inner())
21531            .get(&context.id)
21532            .is_none());
21533        assert!(shared
21534            .replay_spawn_spec(&spec, Instant::now())
21535            .unwrap()
21536            .is_none());
21537    }
21538
21539    #[test]
21540    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21541        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21542        let address = terminal_address(1);
21543        let selection = shared
21544            .select_environment_profile(
21545                address.session.instance_id,
21546                &agent("claude"),
21547                SessionMode::Pty,
21548                Some(&environment_profile),
21549            )
21550            .unwrap()
21551            .unwrap();
21552        shared.bind_session_with_policy(
21553            &address,
21554            ProviderRuntimePolicy::raw_pty(),
21555            Some(environment_profile.clone()),
21556        );
21557        selection.retain();
21558        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21559        assert!(matches!(
21560            runtime.remove_native_launch_profile(&native_id),
21561            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21562        ));
21563        assert!(shared.remove_binding(&address).is_some());
21564        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21565
21566        let mismatched = ResolvedEnvironmentProfileReceipt {
21567            profile_id: environment_profile.profile_id,
21568            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21569                "local-claude-r2",
21570            )
21571            .unwrap(),
21572            network_allowlist: None,
21573            browser_profile_id: None,
21574        };
21575        let error = match shared.select_environment_profile(
21576                AgentInstanceId(42),
21577                &agent("claude"),
21578                SessionMode::Pty,
21579                Some(&mismatched),
21580            ) {
21581            Ok(_) => panic!("mismatched environment profile revision was selected"),
21582            Err(error) => error,
21583        };
21584        assert_eq!(
21585            error.code,
21586            NodeFailureCode::EnvironmentProfileBindingMismatch,
21587        );
21588        assert!(!shared
21589            .session_bindings
21590            .lock()
21591            .unwrap_or_else(|poisoned| poisoned.into_inner())
21592            .contains_key(&AgentInstanceId(42)));
21593    }
21594
21595    #[test]
21596    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21597        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21598        let address = terminal_address(1);
21599        let selection = shared
21600            .select_environment_profile(
21601                address.session.instance_id,
21602                &agent("claude"),
21603                SessionMode::Pty,
21604                Some(&environment_profile),
21605            )
21606            .unwrap()
21607            .unwrap();
21608        let overlay = NativeInstanceLaunchOverlay::new(
21609            agent("claude"),
21610            TransportKind::Pty,
21611            vec![EnvMutation {
21612                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21613                value: Some(OsString::from("fixture-secret")),
21614            }],
21615            vec![
21616                OsString::from("--plugin-dir"),
21617                std::env::current_dir().unwrap().into_os_string(),
21618            ],
21619        )
21620        .unwrap();
21621        let instance_overlay = shared
21622            .install_prepared_launch_overlay(
21623                address.session.instance_id,
21624                PreparedNativeLaunchOverlay::Instance(overlay),
21625            )
21626            .unwrap()
21627            .unwrap();
21628        let bundle = ResolvedBundleReceipt {
21629            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21630            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21631            digest: crate::protocol::SpawnBundleDigest::new(format!(
21632                "sha256:{}",
21633                "0".repeat(64),
21634            ))
21635            .unwrap(),
21636        };
21637        shared.bind_session_with_materialization(
21638            &address,
21639            ProviderRuntimePolicy::raw_pty(),
21640            Some(environment_profile),
21641            Some(bundle),
21642            None,
21643            None,
21644        );
21645        selection.retain();
21646        instance_overlay.retain();
21647
21648        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21649        assert!(matches!(
21650            runtime.remove_native_launch_profile(&native_id),
21651            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21652        ));
21653        assert!(shared.remove_binding(&address).is_some());
21654        assert!(!shared
21655            .native_launch_profile_control
21656            .as_ref()
21657            .unwrap()
21658            .clear_native_instance_launch_overlay(address.session.instance_id));
21659        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21660    }
21661
21662    #[test]
21663    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21664        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21665        let address = SessionAddress {
21666            workspace_id: WorkspaceId::new("primary").unwrap(),
21667            session: SessionKey {
21668                instance_id: AgentInstanceId(201),
21669                generation: SessionGeneration(1),
21670            },
21671        };
21672        let error = match shared.prepare_session_materialization(
21673            &address,
21674            &agent("claude"),
21675            SessionMode::Pty,
21676            Some(&receipt),
21677            None,
21678            None,
21679            None,
21680        ) {
21681            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21682            Err(error) => error,
21683        };
21684        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21685        assert!(shared.handle.snapshot().sessions.is_empty());
21686        assert!(shared
21687            .session_bindings
21688            .lock()
21689            .unwrap_or_else(|poisoned| poisoned.into_inner())
21690            .is_empty());
21691        assert!(shared
21692            .materializations
21693            .lock()
21694            .unwrap_or_else(|poisoned| poisoned.into_inner())
21695            .is_empty());
21696        drop(shared);
21697        drop(runtime);
21698        std::fs::remove_dir_all(root).unwrap();
21699    }
21700
21701    #[test]
21702    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21703        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21704        let address = SessionAddress {
21705            workspace_id: WorkspaceId::new("primary").unwrap(),
21706            session: SessionKey {
21707                instance_id: AgentInstanceId(202),
21708                generation: SessionGeneration(1),
21709            },
21710        };
21711        let (overlay, materialization_guard) = shared
21712            .prepare_session_materialization(
21713                &address,
21714                &agent("claude"),
21715                SessionMode::Pty,
21716                Some(&receipt),
21717                None,
21718                None,
21719                None,
21720            )
21721            .unwrap()
21722            .unwrap();
21723        let materialization_id = materialization_guard.id().unwrap().clone();
21724        let materialization_root = shared
21725            .materializations
21726            .lock()
21727            .unwrap_or_else(|poisoned| poisoned.into_inner())
21728            .get(&materialization_id)
21729            .unwrap()
21730            .root()
21731            .to_path_buf();
21732        let selection = shared
21733            .select_environment_profile(
21734                address.session.instance_id,
21735                &agent("claude"),
21736                SessionMode::Pty,
21737                Some(&receipt),
21738            )
21739            .unwrap()
21740            .unwrap();
21741        assert!(shared
21742            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21743            .unwrap()
21744            .is_none());
21745        shared.bind_session_with_materialization(
21746            &address,
21747            ProviderRuntimePolicy::raw_pty(),
21748            Some(receipt),
21749            None,
21750            None,
21751            Some(materialization_id),
21752        );
21753        selection.retain();
21754        materialization_guard.retain();
21755
21756        assert!(materialization_root.is_dir());
21757        assert!(shared
21758            .session_bindings
21759            .lock()
21760            .unwrap_or_else(|poisoned| poisoned.into_inner())
21761            .contains_key(&address.session.instance_id));
21762
21763        let removed = shared.remove_binding(&address).unwrap();
21764        shared
21765            .cleanup_session_owned_materialization(&address, &removed)
21766            .unwrap();
21767        assert!(!materialization_root.exists());
21768        assert!(shared
21769            .materializations
21770            .lock()
21771            .unwrap_or_else(|poisoned| poisoned.into_inner())
21772            .is_empty());
21773        drop(shared);
21774        drop(runtime);
21775        std::fs::remove_dir_all(root).unwrap();
21776    }
21777
21778    #[tokio::test]
21779    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21780        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21781        let address = SessionAddress {
21782            workspace_id: WorkspaceId::new("primary").unwrap(),
21783            session: SessionKey {
21784                instance_id: AgentInstanceId(203),
21785                generation: SessionGeneration(1),
21786            },
21787        };
21788        let (overlay, materialization_guard) = shared
21789            .prepare_session_materialization(
21790                &address,
21791                &agent("claude"),
21792                SessionMode::Pty,
21793                Some(&receipt),
21794                None,
21795                None,
21796                None,
21797            )
21798            .unwrap()
21799            .unwrap();
21800        let materialization_id = materialization_guard.id().unwrap().clone();
21801        let materialization_root = shared
21802            .materializations
21803            .lock()
21804            .unwrap_or_else(|poisoned| poisoned.into_inner())
21805            .get(&materialization_id)
21806            .unwrap()
21807            .root()
21808            .to_path_buf();
21809        let selection = shared
21810            .select_environment_profile(
21811                address.session.instance_id,
21812                &agent("claude"),
21813                SessionMode::Pty,
21814                Some(&receipt),
21815            )
21816            .unwrap()
21817            .unwrap();
21818        assert!(shared
21819            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21820            .unwrap()
21821            .is_none());
21822        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21823        let record_id = shared
21824            .bind_spawn_session_with_materialization(
21825                &address,
21826                agent("claude"),
21827                SessionMode::Pty,
21828                policy,
21829                SpawnRecordPolicy::ProviderIdentityOnly,
21830                Some(receipt.clone()),
21831                None,
21832                None,
21833                Some(materialization_id.clone()),
21834            )
21835            .unwrap()
21836            .unwrap();
21837        selection.retain();
21838        materialization_guard.retain();
21839        assert_eq!(
21840            shared
21841                .materializations
21842                .lock()
21843                .unwrap_or_else(|poisoned| poisoned.into_inner())
21844                .get(&materialization_id)
21845                .unwrap()
21846                .owner(),
21847            &MaterializationOwner::Record {
21848                record_id: record_id.clone(),
21849            },
21850        );
21851
21852        let removed = shared.remove_binding(&address).unwrap();
21853        shared
21854            .cleanup_session_owned_materialization(&address, &removed)
21855            .unwrap();
21856        assert!(materialization_root.is_dir());
21857        assert!(shared
21858            .resolve_record_materialization(
21859                &record_id,
21860                &agent("claude"),
21861                SessionMode::Pty,
21862                Some(&receipt),
21863                None,
21864                None,
21865            )
21866            .unwrap()
21867            .is_some());
21868        {
21869            let mut records = shared
21870                .session_records
21871                .lock()
21872                .unwrap_or_else(|poisoned| poisoned.into_inner());
21873            let record = records.records.get_mut(&record_id).unwrap();
21874            record.active_session = None;
21875            record.state = ManagedSessionState::Unavailable;
21876        }
21877        shared.forget_session_record(&record_id).await.unwrap();
21878        assert!(!materialization_root.exists());
21879        assert!(shared
21880            .materializations
21881            .lock()
21882            .unwrap_or_else(|poisoned| poisoned.into_inner())
21883            .is_empty());
21884        drop(shared);
21885        drop(runtime);
21886        std::fs::remove_dir_all(root).unwrap();
21887    }
21888
21889    #[test]
21890    fn record_materialization_revalidation_failure_persists_recovery_required() {
21891        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21892        let state_path = root.join("node-state-v6.json");
21893        shared.state_path = Some(state_path.clone());
21894        let (_address, _record_id, materialization_id, materialization_root) =
21895            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21896        std::fs::write(
21897            materialization_root.join(".gate4agent-materialization-owner"),
21898            b"tampered",
21899        )
21900        .unwrap();
21901
21902        let error = match shared.resolve_record_materialization(
21903            &_record_id,
21904            &agent("claude"),
21905            SessionMode::Pty,
21906            Some(&receipt),
21907            None,
21908            None,
21909        ) {
21910            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21911            Err(error) => error,
21912        };
21913        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21914        assert_eq!(
21915            shared
21916                .materializations
21917                .lock()
21918                .unwrap_or_else(|poisoned| poisoned.into_inner())
21919                .get(&materialization_id)
21920                .unwrap()
21921                .state(),
21922            MaterializationState::RecoveryRequired,
21923        );
21924        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21925        assert_eq!(loaded.materializations.len(), 1);
21926        assert_eq!(
21927            loaded.materializations[0].state(),
21928            MaterializationState::RecoveryRequired,
21929        );
21930        drop(shared);
21931        drop(runtime);
21932        std::fs::remove_dir_all(root).unwrap();
21933    }
21934
21935    #[test]
21936    fn transient_record_materialization_resolution_failure_remains_ready() {
21937        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21938        let (_address, record_id, materialization_id, _materialization_root) =
21939            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21940        deny.store(true, Ordering::Release);
21941
21942        let error = match shared.resolve_record_materialization(
21943            &record_id,
21944            &agent("claude"),
21945            SessionMode::Pty,
21946            Some(&receipt),
21947            None,
21948            None,
21949        ) {
21950            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21951            Err(error) => error,
21952        };
21953        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21954        assert_eq!(
21955            shared
21956                .materializations
21957                .lock()
21958                .unwrap_or_else(|poisoned| poisoned.into_inner())
21959                .get(&materialization_id)
21960                .unwrap()
21961                .state(),
21962            MaterializationState::Ready,
21963        );
21964        drop(shared);
21965        drop(runtime);
21966        std::fs::remove_dir_all(root).unwrap();
21967    }
21968
21969    #[test]
21970    fn startup_missing_record_materialization_is_not_resumable() {
21971        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21972        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21973        shared
21974            .insert_record(ManagedSessionRecord {
21975                record_id: record_id.clone(),
21976                display_name: "missing materialization".to_owned(),
21977                provider: agent("claude"),
21978                mode: SessionMode::Pty,
21979                state: ManagedSessionState::Dormant,
21980                workspace_id: WorkspaceId::new("primary").unwrap(),
21981                canonical_root: opaque_windows_path(
21982                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21983                ),
21984                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21985                    key: gate4agent_types::ProviderSessionKey::SessionId,
21986                    id: "provider-session-missing-materialization".to_owned(),
21987                    transcript_path: None,
21988                }),
21989                active_session: None,
21990                environment_profile: Some(receipt),
21991                bundle: None,
21992                context_id: None,
21993                context: None,
21994                exported_context: None,
21995                task_binding: None,
21996                created_at_unix_ms: 1,
21997                updated_at_unix_ms: 1,
21998                last_error: None,
21999            })
22000            .unwrap();
22001
22002        shared.reconcile_materializations();
22003        let record = shared.record(&record_id).unwrap();
22004        assert_eq!(record.state, ManagedSessionState::Unavailable);
22005        assert!(record.provider_session.is_none());
22006        assert_eq!(
22007            record.last_error.as_deref(),
22008            Some("environment-profile-unavailable"),
22009        );
22010        drop(shared);
22011        drop(runtime);
22012        std::fs::remove_dir_all(root).unwrap();
22013    }
22014
22015    #[tokio::test]
22016    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22017        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22018        let lease = install_managed_lease(&shared);
22019        let address = SessionAddress {
22020            workspace_id: WorkspaceId::new("primary").unwrap(),
22021            session: SessionKey {
22022                instance_id: AgentInstanceId(204),
22023                generation: SessionGeneration(1),
22024            },
22025        };
22026        let (overlay, materialization_guard) = shared
22027            .prepare_session_materialization(
22028                &address,
22029                &agent("claude"),
22030                SessionMode::Pty,
22031                Some(&receipt),
22032                None,
22033                None,
22034                Some(lease.lease_id.clone()),
22035            )
22036            .unwrap()
22037            .unwrap();
22038        let materialization_id = materialization_guard.id().unwrap().clone();
22039        let materialization_root = shared
22040            .materializations
22041            .lock()
22042            .unwrap_or_else(|poisoned| poisoned.into_inner())
22043            .get(&materialization_id)
22044            .unwrap()
22045            .root()
22046            .to_path_buf();
22047        let selection = shared
22048            .select_environment_profile(
22049                address.session.instance_id,
22050                &agent("claude"),
22051                SessionMode::Pty,
22052                Some(&receipt),
22053            )
22054            .unwrap()
22055            .unwrap();
22056        assert!(shared
22057            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22058            .unwrap()
22059            .is_none());
22060        shared.bind_session_with_materialization(
22061            &address,
22062            ProviderRuntimePolicy::raw_pty(),
22063            Some(receipt),
22064            None,
22065            None,
22066            Some(materialization_id),
22067        );
22068        selection.retain();
22069        materialization_guard.retain();
22070
22071        let blocked = shared
22072            .cleanup_managed_worktree(&lease.lease_id, false)
22073            .await
22074            .unwrap_err();
22075        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22076        assert!(materialization_root.is_dir());
22077        let removed = shared.remove_binding(&address).unwrap();
22078        shared
22079            .cleanup_session_owned_materialization(&address, &removed)
22080            .unwrap();
22081        assert!(!materialization_root.exists());
22082        drop(shared);
22083        drop(runtime);
22084        std::fs::remove_dir_all(root).unwrap();
22085    }
22086
22087    #[tokio::test]
22088    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22089        let git_root = temporary_workspace_root("raw-managed-inventory");
22090        let source = git_root.join("source");
22091        let allocation = git_root.join("allocation");
22092        std::fs::create_dir_all(&source).unwrap();
22093        std::fs::create_dir_all(&allocation).unwrap();
22094        run_test_git(&source, &["init"]);
22095        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22096        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22097        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22098        run_test_git(&source, &["add", "seed.txt"]);
22099        run_test_git(&source, &["commit", "-m", "seed"]);
22100        let profile = ManagedWorktreeProfile::new(
22101            WorktreeProfileId::new("default").unwrap(),
22102            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22103            &allocation,
22104            "gate4agent",
22105            "HEAD",
22106            ManagedWorktreeRetention::RemoveWhenReleased,
22107        )
22108        .unwrap();
22109        let workspace = WorkspaceConfig::new(
22110            WorkspaceId::new("primary").unwrap(),
22111            &source,
22112        )
22113        .unwrap()
22114        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22115        .with_managed_worktree_profile(profile.clone())
22116        .unwrap();
22117        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22118            materialization_test_shared_with_workspace(false, workspace);
22119        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22120        let source_text = source.to_string_lossy().into_owned();
22121        let target_text = target.to_string_lossy().into_owned();
22122        let created = create_git_worktree(
22123            &source_text,
22124            &target_text,
22125            "gate4agent/mw-raw-inventory",
22126            Some("HEAD"),
22127        )
22128        .await
22129        .unwrap();
22130        let lease = ManagedWorktreeLeaseRecord {
22131            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22132            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22133            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22134            profile_id: profile.profile_id().clone(),
22135            profile_revision: profile.revision().clone(),
22136            target_root: created.path.clone(),
22137            branch: "gate4agent/mw-raw-inventory".to_owned(),
22138            base_commit: created.head.clone(),
22139            expected_head: Some(created.head.clone()),
22140            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22141            state: ManagedWorktreeLeaseState::Ready,
22142            session_holders: Vec::new(),
22143            record_holders: Vec::new(),
22144            cleanup_failure: None,
22145            created_at_unix_ms: 1,
22146            updated_at_unix_ms: 1,
22147        };
22148        shared
22149            .workspaces
22150            .write()
22151            .unwrap_or_else(|poisoned| poisoned.into_inner())
22152            .insert(lease.workspace_id.clone(), created.path.clone());
22153        *shared
22154            .managed_worktrees
22155            .lock()
22156            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22157            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22158        let address = SessionAddress {
22159            workspace_id: lease.workspace_id.clone(),
22160            session: SessionKey {
22161                instance_id: AgentInstanceId(205),
22162                generation: SessionGeneration(1),
22163            },
22164        };
22165        let (overlay, materialization_guard) = shared
22166            .prepare_session_materialization(
22167                &address,
22168                &agent("claude"),
22169                SessionMode::Pty,
22170                Some(&receipt),
22171                None,
22172                None,
22173                Some(lease.lease_id.clone()),
22174            )
22175            .unwrap()
22176            .unwrap();
22177        let materialization_id = materialization_guard.id().unwrap().clone();
22178        let materialization_root = shared
22179            .materializations
22180            .lock()
22181            .unwrap_or_else(|poisoned| poisoned.into_inner())
22182            .get(&materialization_id)
22183            .unwrap()
22184            .root()
22185            .to_path_buf();
22186        let selection = shared
22187            .select_environment_profile(
22188                address.session.instance_id,
22189                &agent("claude"),
22190                SessionMode::Pty,
22191                Some(&receipt),
22192            )
22193            .unwrap()
22194            .unwrap();
22195        assert!(shared
22196            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22197            .unwrap()
22198            .is_none());
22199        let record_id = shared
22200            .bind_spawn_session_with_materialization(
22201                &address,
22202                agent("claude"),
22203                SessionMode::Pty,
22204                ProviderRuntimePolicy::raw_pty(),
22205                SpawnRecordPolicy::Always,
22206                Some(receipt.clone()),
22207                None,
22208                None,
22209                Some(materialization_id.clone()),
22210            )
22211            .unwrap()
22212            .unwrap();
22213        selection.retain();
22214        materialization_guard.retain();
22215        let record = shared.record(&record_id).unwrap();
22216        assert_eq!(record.state, ManagedSessionState::Live);
22217        assert!(record.provider_session.is_none());
22218        assert!(record.environment_profile.is_none());
22219        assert!(record.bundle.is_none());
22220        assert!(record.context_id.is_none());
22221        assert!(record.context.is_none());
22222        assert_eq!(record.active_session.as_ref(), Some(&address));
22223        assert_eq!(
22224            shared
22225                .materializations
22226                .lock()
22227                .unwrap_or_else(|poisoned| poisoned.into_inner())
22228                .get(&materialization_id)
22229                .unwrap()
22230                .owner(),
22231            &MaterializationOwner::Session {
22232                incarnation_id: shared.incarnation_id,
22233                instance_id: address.session.instance_id,
22234                generation: address.session.generation,
22235            },
22236        );
22237        {
22238            let mut bindings = shared
22239                .session_bindings
22240                .lock()
22241                .unwrap_or_else(|poisoned| poisoned.into_inner());
22242            bindings
22243                .get_mut(&address.session.instance_id)
22244                .unwrap()
22245                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22246        }
22247        let lease_snapshot = shared
22248            .managed_worktrees
22249            .lock()
22250            .unwrap_or_else(|poisoned| poisoned.into_inner())
22251            .bind_session(
22252                &lease.lease_id,
22253                ManagedWorktreeSessionHolder {
22254                    incarnation_id: shared.incarnation_id,
22255                    instance_id: address.session.instance_id,
22256                    generation: address.session.generation,
22257                },
22258                None,
22259                unix_time_ms(),
22260            )
22261            .unwrap();
22262        assert_eq!(lease_snapshot.active_session_count, 1);
22263        assert_eq!(lease_snapshot.managed_record_count, 0);
22264        shared.persist_state().unwrap();
22265        assert!(shared
22266            .managed_worktrees
22267            .lock()
22268            .unwrap_or_else(|poisoned| poisoned.into_inner())
22269            .get(&lease.lease_id)
22270            .unwrap()
22271            .record_holders
22272            .is_empty());
22273        shared
22274            .handle
22275            .dispatch(shared.prepare_command(ControlCommand::Register {
22276                instance_id: address.session.instance_id,
22277                agent_id: agent("claude"),
22278                transport: TransportKind::Pty,
22279            }))
22280            .unwrap();
22281        runtime.tick().await;
22282        shared.reconcile_managed_record(
22283            &record_id,
22284            &address,
22285            &ControlEvent {
22286                sequence: 1,
22287                command_id: None,
22288                instance_id: address.session.instance_id,
22289                generation: address.session.generation,
22290                event: ControlEventKind::Removed,
22291            },
22292            false,
22293            false,
22294        );
22295        let remove = shared.remove_session(&address);
22296        let drive_runtime = async {
22297            runtime.tick().await;
22298        };
22299        let (removed, ()) = tokio::join!(remove, drive_runtime);
22300        removed.unwrap();
22301
22302        assert!(!materialization_root.exists());
22303        assert!(!Path::new(&created.path).exists());
22304        assert_eq!(
22305            shared
22306                .managed_worktrees
22307                .lock()
22308                .unwrap_or_else(|poisoned| poisoned.into_inner())
22309                .get(&lease.lease_id)
22310                .unwrap()
22311                .state,
22312            ManagedWorktreeLeaseState::Removed,
22313        );
22314        let renamed = shared
22315            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22316            .unwrap();
22317        assert_eq!(renamed.record_id, record_id);
22318        assert_eq!(renamed.display_name, "raw managed inventory");
22319        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22320        assert!(renamed.active_session.is_none());
22321
22322        drop(shared);
22323        drop(runtime);
22324        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22325        std::fs::remove_dir_all(git_root).unwrap();
22326    }
22327
22328    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22329        let root = std::env::current_dir().unwrap()
22330            .join("managed-test-target")
22331            .to_string_lossy()
22332            .into_owned();
22333        let lease = ManagedWorktreeLeaseRecord {
22334            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22335            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22336            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22337            profile_id: WorktreeProfileId::new("default").unwrap(),
22338            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22339            target_root: root.clone(),
22340            branch: "gate4agent/mw-test".to_owned(),
22341            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22342            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22343            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22344            state: ManagedWorktreeLeaseState::Ready,
22345            session_holders: Vec::new(),
22346            record_holders: Vec::new(),
22347            cleanup_failure: None,
22348            created_at_unix_ms: 1,
22349            updated_at_unix_ms: 1,
22350        };
22351        shared.workspaces.write()
22352            .unwrap_or_else(|poisoned| poisoned.into_inner())
22353            .insert(lease.workspace_id.clone(), root);
22354        *shared.managed_worktrees.lock()
22355            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22356            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22357        lease
22358    }
22359
22360    #[cfg(feature = "fixture")]
22361    #[test]
22362    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22363        let mut shared = terminal_test_shared();
22364        shared.fixture_semantic_hook_policy = true;
22365        let lease = install_managed_lease(&shared);
22366        let address = SessionAddress {
22367            workspace_id: lease.workspace_id.clone(),
22368            session: SessionKey {
22369                instance_id: AgentInstanceId(206),
22370                generation: SessionGeneration(1),
22371            },
22372        };
22373        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22374        let effective_runtime_policy =
22375            shared.effective_spawn_runtime_policy(pre_admission_policy);
22376        assert_ne!(effective_runtime_policy, pre_admission_policy);
22377        assert!(effective_runtime_policy.provider_session_identity);
22378
22379        let record_id = shared
22380            .bind_spawn_session(
22381                &address,
22382                agent("claude"),
22383                SessionMode::Pty,
22384                effective_runtime_policy,
22385                SpawnRecordPolicy::Always,
22386                None,
22387            )
22388            .unwrap()
22389            .unwrap();
22390        assert_eq!(
22391            shared.record(&record_id).unwrap().state,
22392            ManagedSessionState::IdentityPending,
22393        );
22394        let (record_holder, raw_inventory_record_id) = {
22395            let mut bindings = shared
22396                .session_bindings
22397                .lock()
22398                .unwrap_or_else(|poisoned| poisoned.into_inner());
22399            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22400            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22401            managed_spawn_record_ownership(binding, effective_runtime_policy)
22402        };
22403        assert_eq!(record_holder.as_ref(), Some(&record_id));
22404        assert!(raw_inventory_record_id.is_none());
22405        let snapshot = shared
22406            .managed_worktrees
22407            .lock()
22408            .unwrap_or_else(|poisoned| poisoned.into_inner())
22409            .bind_session(
22410                &lease.lease_id,
22411                ManagedWorktreeSessionHolder {
22412                    incarnation_id: shared.incarnation_id,
22413                    instance_id: address.session.instance_id,
22414                    generation: address.session.generation,
22415                },
22416                record_holder,
22417                unix_time_ms(),
22418            )
22419            .unwrap();
22420        assert_eq!(snapshot.managed_record_count, 1);
22421        assert_eq!(
22422            shared
22423                .managed_worktrees
22424                .lock()
22425                .unwrap_or_else(|poisoned| poisoned.into_inner())
22426                .get(&lease.lease_id)
22427                .unwrap()
22428                .record_holders,
22429            vec![record_id.clone()],
22430        );
22431
22432        let identity = ProviderSessionIdentity {
22433            key: ProviderSessionKey::SessionId,
22434            id: "monitoring-fixture-managed-session".to_owned(),
22435            transcript_path: None,
22436        };
22437        shared.publish_control(ControlEvent {
22438            sequence: 1,
22439            command_id: None,
22440            instance_id: address.session.instance_id,
22441            generation: address.session.generation,
22442            event: ControlEventKind::ProviderEvent {
22443                sequence: 1,
22444                source: gate4agent_types::ProviderSource {
22445                    family: AdapterFamily::Hook,
22446                    binding: AdapterBinding::new(
22447                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22448                        "fixture/v1",
22449                        gate4agent_types::AdapterVerification::SyntheticFixture,
22450                    )
22451                    .unwrap(),
22452                },
22453                source_sequence: 1,
22454                event: ProviderEvent::SessionIdentityObserved {
22455                    identity: identity.clone(),
22456                },
22457            },
22458        });
22459
22460        let live = shared.record(&record_id).unwrap();
22461        assert_eq!(live.state, ManagedSessionState::Live);
22462        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22463        assert_eq!(live.active_session.as_ref(), Some(&address));
22464        assert_eq!(
22465            shared
22466                .managed_worktrees
22467                .lock()
22468                .unwrap_or_else(|poisoned| poisoned.into_inner())
22469                .get(&lease.lease_id)
22470                .unwrap()
22471                .record_holders,
22472            vec![record_id],
22473        );
22474    }
22475
22476    fn managed_v2_test_shared(
22477        allocation_root: &Path,
22478        state_path: Option<PathBuf>,
22479        profile_revision: &str,
22480    ) -> NodeShared {
22481        let profile = ManagedWorktreeProfile::new(
22482            WorktreeProfileId::new("default").unwrap(),
22483            WorktreeProfileRevision::new(profile_revision).unwrap(),
22484            allocation_root,
22485            "gate4agent",
22486            "HEAD",
22487            ManagedWorktreeRetention::RemoveWhenReleased,
22488        )
22489        .unwrap();
22490        let workspace = WorkspaceConfig::new(
22491            WorkspaceId::new("primary").unwrap(),
22492            std::env::current_dir().unwrap(),
22493        )
22494        .unwrap()
22495        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22496        .with_managed_worktree_profile(profile)
22497        .unwrap();
22498        let catalog = active_registry().unwrap();
22499        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22500        let mut shared = NodeShared::new(
22501            handle,
22502            "fixture-token".to_owned(),
22503            NodeId::new("node-terminal-test").unwrap(),
22504            vec![workspace],
22505            vec![agent("claude")],
22506        );
22507        shared.state_path = state_path;
22508        shared
22509    }
22510
22511    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22512        let mut spawn_spec = spawn_spec_fixture();
22513        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22514        ManagedWorktreeSpawnRequestV2 {
22515            spawn_spec,
22516            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22517            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22518        }
22519    }
22520
22521    #[test]
22522    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22523        let shared = terminal_test_shared();
22524        let mut lease = install_managed_lease(&shared);
22525        let session = SessionAddress {
22526            workspace_id: lease.workspace_id.clone(),
22527            session: SessionKey {
22528                instance_id: AgentInstanceId(73),
22529                generation: SessionGeneration(4),
22530            },
22531        };
22532        lease.state = ManagedWorktreeLeaseState::InUse;
22533        lease.session_holders.push(ManagedWorktreeSessionHolder {
22534            incarnation_id: shared.incarnation_id,
22535            instance_id: session.session.instance_id,
22536            generation: session.session.generation,
22537        });
22538        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22539        assert!(resolved.target.worktree_id.is_none());
22540
22541        let receipt = managed_spawn_receipt(
22542            &resolved,
22543            shared.incarnation_id,
22544            session.clone(),
22545            lease.snapshot(),
22546            None,
22547            None,
22548            None,
22549        );
22550        assert!(resolved.target.worktree_id.is_none());
22551        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22552        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22553        assert_eq!(receipt.spawn.session, session);
22554        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22555        let encoded = serde_json::to_vec(&receipt).unwrap();
22556        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22557        assert_eq!(decoded, receipt);
22558    }
22559
22560    #[tokio::test]
22561    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22562        let root = temporary_workspace_root("managed-v2-revision");
22563        let allocation = root.join("allocation");
22564        std::fs::create_dir_all(&allocation).unwrap();
22565        let shared = managed_v2_test_shared(
22566            &allocation,
22567            Some(root.join("node-state.json")),
22568            "v1",
22569        );
22570        let mut request = managed_v2_request("managed-v2-revision");
22571        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22572
22573        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22574
22575        assert_eq!(
22576            failure.code,
22577            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22578        );
22579        assert!(shared
22580            .managed_worktrees
22581            .lock()
22582            .unwrap_or_else(|poisoned| poisoned.into_inner())
22583            .records()
22584            .is_empty());
22585        assert!(shared
22586            .managed_spawn_replays
22587            .lock()
22588            .unwrap_or_else(|poisoned| poisoned.into_inner())
22589            .is_empty());
22590        std::fs::remove_dir_all(root).unwrap();
22591    }
22592
22593    #[test]
22594    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22595        let root = temporary_workspace_root("managed-v2-concurrent");
22596        let allocation = root.join("allocation");
22597        std::fs::create_dir_all(&allocation).unwrap();
22598        let shared = Arc::new(managed_v2_test_shared(
22599            &allocation,
22600            Some(root.join("node-state.json")),
22601            "v1",
22602        ));
22603        let request = managed_v2_request("managed-v2-concurrent");
22604        let barrier = Arc::new(std::sync::Barrier::new(3));
22605        let mut joins = Vec::new();
22606        for _ in 0..2 {
22607            let shared = Arc::clone(&shared);
22608            let request = request.clone();
22609            let barrier = Arc::clone(&barrier);
22610            joins.push(std::thread::spawn(move || {
22611                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22612                barrier.wait();
22613                shared.acquire_durable_managed_spawn_v2(&request, digest)
22614            }));
22615        }
22616        barrier.wait();
22617        let results = joins
22618            .into_iter()
22619            .map(|join| join.join().unwrap())
22620            .collect::<Vec<_>>();
22621        assert_eq!(
22622            results
22623                .iter()
22624                .filter(|result| {
22625                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22626                })
22627                .count(),
22628            1,
22629        );
22630        assert_eq!(
22631            results
22632                .iter()
22633                .filter(|result| {
22634                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22635                })
22636                .count(),
22637            1,
22638        );
22639        assert_eq!(
22640            shared
22641                .managed_spawn_replays
22642                .lock()
22643                .unwrap_or_else(|poisoned| poisoned.into_inner())
22644                .len(),
22645            1,
22646        );
22647        assert!(shared
22648            .managed_worktrees
22649            .lock()
22650            .unwrap_or_else(|poisoned| poisoned.into_inner())
22651            .records()
22652            .is_empty());
22653        drop(shared);
22654        std::fs::remove_dir_all(root).unwrap();
22655    }
22656
22657    #[tokio::test]
22658    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22659        let root = temporary_workspace_root("managed-v2-linked-pending");
22660        let allocation = root.join("allocation");
22661        std::fs::create_dir_all(&allocation).unwrap();
22662        let state_path = root.join("node-state.json");
22663        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22664        let lease = install_managed_lease(&shared);
22665        let request = managed_v2_request("managed-v2-linked-pending");
22666        assert!(matches!(
22667            shared
22668                .acquire_durable_managed_spawn_v2(
22669                    &request,
22670                    managed_spawn_request_digest_v2(&request).unwrap(),
22671                )
22672                .unwrap(),
22673            DurableManagedSpawnReplayDecision::Reserved,
22674        ));
22675        {
22676            let _transaction = shared
22677                .state_transaction
22678                .lock()
22679                .unwrap_or_else(|poisoned| poisoned.into_inner());
22680            shared
22681                .link_durable_managed_spawn_v2_lease_locked(
22682                    &request.spawn_spec.idempotency_key,
22683                    &lease.lease_id,
22684                )
22685                .unwrap();
22686            shared.persist_state_locked().unwrap();
22687        }
22688        drop(shared);
22689
22690        let mut loaded = session_registry::load(
22691            Some(&state_path),
22692            &NodeId::new("node-terminal-test").unwrap(),
22693        )
22694        .unwrap();
22695        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22696        *reopened
22697            .managed_worktrees
22698            .lock()
22699            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22700            ManagedWorktreeRegistry::from_records(
22701                std::mem::take(&mut loaded.managed_worktrees),
22702                std::mem::take(&mut loaded.managed_worktree_tombstones),
22703            )
22704            .unwrap();
22705        *reopened
22706            .managed_spawn_replays
22707            .lock()
22708            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22709            .managed_spawn_replays
22710            .into_iter()
22711            .map(|record| (record.idempotency_key.clone(), record))
22712            .collect();
22713        let lease_count = reopened
22714            .managed_worktrees
22715            .lock()
22716            .unwrap_or_else(|poisoned| poisoned.into_inner())
22717            .records()
22718            .len();
22719
22720        let failure = reopened
22721            .spawn_managed_worktree_v2(request)
22722            .await
22723            .unwrap_err();
22724
22725        assert_eq!(
22726            failure.code,
22727            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22728        );
22729        assert_eq!(
22730            reopened
22731                .managed_worktrees
22732                .lock()
22733                .unwrap_or_else(|poisoned| poisoned.into_inner())
22734                .records()
22735                .len(),
22736            lease_count,
22737        );
22738        std::fs::remove_dir_all(root).unwrap();
22739    }
22740
22741    #[tokio::test]
22742    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22743        let root = temporary_workspace_root("managed-v2-reopen");
22744        let allocation = root.join("allocation");
22745        std::fs::create_dir_all(&allocation).unwrap();
22746        let state_path = root.join("node-state.json");
22747        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22748        let lease = install_managed_lease(&shared);
22749        let request = managed_v2_request("managed-v2-reopen");
22750        let session = SessionAddress {
22751            workspace_id: lease.workspace_id.clone(),
22752            session: SessionKey {
22753                instance_id: AgentInstanceId(91),
22754                generation: SessionGeneration(1),
22755            },
22756        };
22757        let snapshot = {
22758            let mut registry = shared
22759                .managed_worktrees
22760                .lock()
22761                .unwrap_or_else(|poisoned| poisoned.into_inner());
22762            let current = registry.get_mut(&lease.lease_id).unwrap();
22763            current.state = ManagedWorktreeLeaseState::InUse;
22764            current.session_holders.push(ManagedWorktreeSessionHolder {
22765                incarnation_id: shared.incarnation_id,
22766                instance_id: session.session.instance_id,
22767                generation: session.session.generation,
22768            });
22769            current.snapshot()
22770        };
22771        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22772        let receipt = managed_spawn_receipt(
22773            &resolved,
22774            shared.incarnation_id,
22775            session,
22776            snapshot,
22777            None,
22778            None,
22779            None,
22780        );
22781        assert!(matches!(
22782            shared
22783                .acquire_durable_managed_spawn_v2(
22784                    &request,
22785                    managed_spawn_request_digest_v2(&request).unwrap(),
22786                )
22787                .unwrap(),
22788            DurableManagedSpawnReplayDecision::Reserved,
22789        ));
22790        {
22791            let _transaction = shared
22792                .state_transaction
22793                .lock()
22794                .unwrap_or_else(|poisoned| poisoned.into_inner());
22795            shared
22796                .link_durable_managed_spawn_v2_lease_locked(
22797                    &request.spawn_spec.idempotency_key,
22798                    &lease.lease_id,
22799                )
22800                .unwrap();
22801            shared.persist_state_locked().unwrap();
22802        }
22803        shared
22804            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22805            .unwrap();
22806        let legacy_same_key = ManagedWorktreeSpawnRequest {
22807            spawn_spec: request.spawn_spec.clone(),
22808            worktree_profile_id: request.worktree_profile_id.clone(),
22809        };
22810        shared.remember_managed_spawn_attempt(
22811            Some(&request.spawn_spec.idempotency_key),
22812            legacy_same_key.clone(),
22813            Ok(receipt.clone()),
22814            Instant::now(),
22815        );
22816        assert!(shared
22817            .replay_managed_spawn(&legacy_same_key, Instant::now())
22818            .unwrap()
22819            .is_none());
22820        drop(shared);
22821
22822        let mut loaded = session_registry::load(
22823            Some(&state_path),
22824            &NodeId::new("node-terminal-test").unwrap(),
22825        )
22826        .unwrap();
22827        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22828        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22829        *reopened
22830            .managed_worktrees
22831            .lock()
22832            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22833            ManagedWorktreeRegistry::from_records(
22834                std::mem::take(&mut loaded.managed_worktrees),
22835                std::mem::take(&mut loaded.managed_worktree_tombstones),
22836            )
22837            .unwrap();
22838        *reopened
22839            .managed_spawn_replays
22840            .lock()
22841            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22842            .managed_spawn_replays
22843            .into_iter()
22844            .map(|record| (record.idempotency_key.clone(), record))
22845            .collect();
22846        let lease_count_before = reopened
22847            .managed_worktrees
22848            .lock()
22849            .unwrap_or_else(|poisoned| poisoned.into_inner())
22850            .records()
22851            .len();
22852
22853        assert_eq!(
22854            reopened
22855                .spawn_managed_worktree_v2(request.clone())
22856                .await
22857                .unwrap(),
22858            receipt,
22859        );
22860        assert_eq!(
22861            reopened
22862                .managed_worktrees
22863                .lock()
22864                .unwrap_or_else(|poisoned| poisoned.into_inner())
22865                .records()
22866                .len(),
22867            lease_count_before,
22868        );
22869        let mut changed = request;
22870        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22871        let failure = reopened
22872            .spawn_managed_worktree_v2(changed)
22873            .await
22874            .unwrap_err();
22875        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22876        assert_eq!(
22877            reopened
22878                .managed_worktrees
22879                .lock()
22880                .unwrap_or_else(|poisoned| poisoned.into_inner())
22881                .records()
22882                .len(),
22883            lease_count_before,
22884        );
22885        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22886        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22887        let failure = reopened
22888            .spawn_managed_worktree_v2(new_key)
22889            .await
22890            .unwrap_err();
22891        assert_eq!(
22892            failure.code,
22893            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22894        );
22895        assert_eq!(
22896            reopened
22897                .managed_worktrees
22898                .lock()
22899                .unwrap_or_else(|poisoned| poisoned.into_inner())
22900                .records()
22901                .len(),
22902            lease_count_before,
22903        );
22904        assert_eq!(
22905            reopened
22906                .managed_spawn_replays
22907                .lock()
22908                .unwrap_or_else(|poisoned| poisoned.into_inner())
22909                .len(),
22910            1,
22911        );
22912        std::fs::remove_dir_all(root).unwrap();
22913    }
22914
22915    #[test]
22916    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22917        let shared = terminal_test_shared();
22918        let request = ManagedWorktreeSpawnRequest {
22919            spawn_spec: spawn_spec_fixture(),
22920            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22921        };
22922        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22923        shared.remember_managed_spawn(
22924            request.clone(),
22925            Err(expected.clone()),
22926            Instant::now(),
22927        );
22928
22929        assert_eq!(
22930            shared
22931                .replay_managed_spawn(&request, Instant::now())
22932                .unwrap()
22933                .unwrap(),
22934            Err(expected),
22935        );
22936        assert!(shared
22937            .managed_spawn_replays
22938            .lock()
22939            .unwrap_or_else(|poisoned| poisoned.into_inner())
22940            .is_empty());
22941    }
22942
22943    #[tokio::test]
22944    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22945        let shared = terminal_test_shared();
22946        let lease = install_managed_lease(&shared);
22947        let raw = shared.spawn_session(
22948            lease.workspace_id.clone(),
22949            agent("claude"),
22950            SessionMode::Pty,
22951            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22952            None,
22953        ).await.unwrap_err();
22954        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22955        let mut spec = spawn_spec_fixture();
22956        spec.target.workspace_id = lease.workspace_id.clone();
22957        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22958        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22959        let mut selected = spawn_spec_fixture();
22960        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22961        selected.target.worktree_id = Some(lease.workspace_id);
22962        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22963        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22964        assert!(shared.handle.snapshot().sessions.is_empty());
22965    }
22966
22967    #[test]
22968    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22969        let shared = terminal_test_shared();
22970        let lease = install_managed_lease(&shared);
22971        let snapshot = snapshot_for_wire(
22972            &shared, true, true, true, false, true, true, false, false, true,
22973        );
22974        assert!(snapshot.managed_worktrees.is_empty());
22975        assert!(snapshot.workspaces.iter().all(|workspace| {
22976            workspace.managed_worktree_profiles.is_none()
22977        }));
22978        let mut reply = ResponseEnvelope {
22979            request_id: 1,
22980            result: Ok(NodeResponse::Resync {
22981                event_sequence: 1,
22982                oldest_available_sequence: 1,
22983                snapshot: shared.snapshot(),
22984                events: vec![NodeEventEnvelope {
22985                    sequence: 1,
22986                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22987                }],
22988            }),
22989        };
22990        project_response_without_managed_worktrees(&mut reply);
22991        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22992            panic!("projection changed the response kind");
22993        };
22994        assert!(snapshot.managed_worktrees.is_empty());
22995        assert!(events.is_empty());
22996        let mut git = git_snapshot_for_parser();
22997        git.branch = Some(lease.branch.clone());
22998        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22999            lease_id: lease.lease_id.clone(),
23000            source_workspace_id: lease.source_workspace_id.clone(),
23001            branch: lease.branch.clone(),
23002            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23003            active_session_count: 1,
23004            managed_record_count: 0,
23005        });
23006        let mut inspection_reply = ResponseEnvelope {
23007            request_id: 2,
23008            result: Ok(NodeResponse::WorkspaceInspected {
23009                inspection: WorkspaceInspection {
23010                    workspace_id: lease.workspace_id.clone(),
23011                    entries: Vec::new(),
23012                    tree_truncated: false,
23013                    git,
23014                    truncation: None,
23015                },
23016            }),
23017        };
23018        project_response_without_managed_worktrees(&mut inspection_reply);
23019        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23020            panic!("projection changed the inspection response kind");
23021        };
23022        assert!(inspection.git.managed_worktree.is_none());
23023        let failure = managed_git_worktree_failure(GitWorktreeError {
23024            kind: GitWorktreeErrorKind::Failed,
23025            message: r"git failed at C:\private\managed-target".to_owned(),
23026        });
23027        assert!(!failure.message.contains("private"));
23028        assert!(!failure.message.contains("managed-target"));
23029    }
23030
23031    #[tokio::test]
23032    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23033        let root = temporary_workspace_root("managed-restart");
23034        let source = root.join("source");
23035        let allocation = root.join("allocation");
23036        std::fs::create_dir_all(&source).unwrap();
23037        std::fs::create_dir_all(&allocation).unwrap();
23038        run_test_git(&source, &["init"]);
23039        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23040        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23041        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23042        run_test_git(&source, &["add", "seed.txt"]);
23043        run_test_git(&source, &["commit", "-m", "seed"]);
23044        let profile = ManagedWorktreeProfile::new(
23045            WorktreeProfileId::new("default").unwrap(),
23046            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23047            &allocation,
23048            "gate4agent",
23049            "HEAD",
23050            ManagedWorktreeRetention::Retain,
23051        ).unwrap();
23052        let workspace = WorkspaceConfig::new(
23053            WorkspaceId::new("primary").unwrap(),
23054            &source,
23055        ).unwrap()
23056            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23057            .with_managed_worktree_profile(profile.clone()).unwrap();
23058        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23059        let source_text = workspace.canonical_root().to_owned();
23060        let target_text = target.to_string_lossy().into_owned();
23061        let created = create_git_worktree(
23062            &source_text,
23063            &target_text,
23064            "gate4agent/mw-restart",
23065            Some("HEAD"),
23066        ).await.unwrap();
23067        let lease = ManagedWorktreeLeaseRecord {
23068            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23069            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23070            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23071            profile_id: profile.profile_id().clone(),
23072            profile_revision: profile.revision().clone(),
23073            target_root: created.path.clone(),
23074            branch: "gate4agent/mw-restart".to_owned(),
23075            base_commit: created.head.clone(),
23076            expected_head: None,
23077            retention: ManagedWorktreeRetention::Retain,
23078            state: ManagedWorktreeLeaseState::Allocating,
23079            session_holders: Vec::new(),
23080            record_holders: Vec::new(),
23081            cleanup_failure: None,
23082            created_at_unix_ms: 1,
23083            updated_at_unix_ms: 1,
23084        };
23085        let catalog = active_registry().unwrap();
23086        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23087        let shared = NodeShared::new_with_incarnation(
23088            handle,
23089            "fixture-token".to_owned(),
23090            NodeId::new("node-managed-restart").unwrap(),
23091            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23092            vec![workspace],
23093            vec![agent("claude")],
23094            ProviderRuntimeStatuses::default(),
23095            None,
23096            Vec::new(),
23097            Vec::new(),
23098            SpawnProfileRegistry::default(),
23099            None,
23100            None,
23101            None,
23102            None,
23103            Vec::new(),
23104            vec![lease.clone()],
23105            Vec::new(),
23106            Vec::new(),
23107            None,
23108            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23109        );
23110        shared.reconcile_managed_worktrees().await;
23111        let retained = shared.managed_worktrees.lock()
23112            .unwrap_or_else(|poisoned| poisoned.into_inner())
23113            .get(&lease.lease_id).unwrap().clone();
23114        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23115        assert!(shared.workspaces.read()
23116            .unwrap_or_else(|poisoned| poisoned.into_inner())
23117            .contains_key(&lease.workspace_id));
23118        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23119        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23120        assert!(!Path::new(&created.path).exists());
23121        std::fs::remove_dir_all(root).unwrap();
23122    }
23123
23124    fn run_test_git(root: &Path, arguments: &[&str]) {
23125        let output = std::process::Command::new("git")
23126            .arg("-C").arg(root).args(arguments).output().unwrap();
23127        assert!(
23128            output.status.success(),
23129            "git {:?} failed: {}",
23130            arguments,
23131            String::from_utf8_lossy(&output.stderr),
23132        );
23133    }
23134
23135    #[tokio::test]
23136    async fn spawn_spec_validation_fails_before_session_mutation() {
23137        let shared = terminal_test_shared();
23138        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23139            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23140        }));
23141        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23142            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23143        }));
23144        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23145            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23146        }));
23147
23148        let mut target_mismatch = spawn_spec_fixture();
23149        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23150        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23151        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23152
23153        let mut unknown_profile = spawn_spec_fixture();
23154        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23155        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23156        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23157        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23158
23159        let mut revision_mismatch = spawn_spec_fixture();
23160        revision_mismatch.idempotency_key =
23161            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23162        revision_mismatch.expected_profile_revision =
23163            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23164        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23165        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23166
23167        let mut unsupported_materializer = spawn_spec_fixture();
23168        unsupported_materializer.idempotency_key =
23169            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23170        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23171            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23172        };
23173        let failure = shared
23174            .spawn_from_spec(unsupported_materializer)
23175            .await
23176            .unwrap_err();
23177        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23178
23179        let failure = shared
23180            .spawn_session_with_deadline(
23181                WorkspaceId::new("primary").unwrap(),
23182                agent("claude"),
23183                SessionMode::Pty,
23184                gate4agent_types::TerminalSize {
23185                    rows: 24,
23186                    columns: 80,
23187                },
23188                None,
23189                None,
23190                None,
23191                None,
23192                None,
23193                None,
23194                SpawnRecordPolicy::ProviderIdentityOnly,
23195                Some(Instant::now()),
23196                &[],
23197                None,
23198                ApprovalLevel::default(),
23199                None,
23200                None,
23201            )
23202            .await
23203            .unwrap_err();
23204        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23205        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23206        assert!(shared.handle.snapshot().sessions.is_empty());
23207    }
23208
23209    #[test]
23210    fn worktree_selection_requires_its_separate_negotiated_capability() {
23211        let mut spec = spawn_spec_fixture();
23212        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23213        let request = NodeRequest::SpawnSpec { spec };
23214        let spawn_spec_capability =
23215            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23216        assert!(request_uses_unnegotiated_capability(
23217            &request,
23218            &[spawn_spec_capability.clone()],
23219        ));
23220        let worktree_capability =
23221            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23222        assert!(request_uses_unnegotiated_capability(
23223            &request,
23224            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23225        ));
23226        let profile_revision_capability =
23227            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23228        assert!(request_uses_unnegotiated_capability(
23229            &request,
23230            &[
23231                spawn_spec_capability.clone(),
23232                worktree_capability.clone(),
23233                profile_revision_capability.clone(),
23234            ],
23235        ));
23236        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23237        // `Inherit`, not `Clear` — the profile's own defaults might still
23238        // materialize a bundle or a context pack, so both capabilities stay
23239        // conservatively required until negotiated (see
23240        // `requires_session_bundle_materialization_capability` /
23241        // `requires_history_context_pack_capability`).
23242        let bundle_materialization_capability =
23243            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23244        assert!(request_uses_unnegotiated_capability(
23245            &request,
23246            &[
23247                spawn_spec_capability.clone(),
23248                worktree_capability.clone(),
23249                profile_revision_capability.clone(),
23250                bundle_materialization_capability.clone(),
23251            ],
23252        ));
23253        let history_context_pack_capability =
23254            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23255        assert!(!request_uses_unnegotiated_capability(
23256            &request,
23257            &[
23258                spawn_spec_capability,
23259                worktree_capability,
23260                profile_revision_capability,
23261                bundle_materialization_capability,
23262                history_context_pack_capability,
23263            ],
23264        ));
23265    }
23266
23267    #[tokio::test]
23268    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23269        let mut shared = terminal_test_shared();
23270        let spec = spawn_spec_fixture();
23271        let receipt = shared
23272            .resolve_spawn_spec(&spec)
23273            .unwrap()
23274            .receipt(shared.incarnation_id, terminal_address(1));
23275        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23276
23277        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23278        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23279        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23280
23281        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23282
23283        let mut revision_conflicting = spec.clone();
23284        revision_conflicting.expected_profile_revision =
23285            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23286        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23287        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23288
23289        let mut stale_cache_miss = spec.clone();
23290        stale_cache_miss.idempotency_key =
23291            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23292        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23293        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23294
23295        let mut conflicting = spec;
23296        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23297        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23298        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23299        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23300        assert!(shared.handle.snapshot().sessions.is_empty());
23301    }
23302
23303    #[tokio::test]
23304    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23305        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23306        let mut spec = spawn_spec_fixture();
23307        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23308        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23309        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23310
23311        let failure = shared
23312            .create_worktree(
23313                WorkspaceId::new("primary").unwrap(),
23314                WorkspaceId::new("new-worktree").unwrap(),
23315                "not-an-absolute-path".to_owned(),
23316                "invalid branch".to_owned(),
23317                None,
23318            )
23319            .await
23320            .unwrap_err();
23321        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23322
23323        let failure = shared
23324            .remove_worktree(
23325                WorkspaceId::new("primary").unwrap(),
23326                "not-an-absolute-path".to_owned(),
23327            )
23328            .await
23329            .unwrap_err();
23330        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23331        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23332        assert!(shared.handle.snapshot().sessions.is_empty());
23333    }
23334
23335    #[tokio::test]
23336    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23337        let root = temporary_workspace_root("manual-worktree-compensation");
23338        let source = root.join("source");
23339        let target = root.join("target");
23340        let invalid_state_parent = root.join("state-parent-is-a-file");
23341        let invalid_state_path = invalid_state_parent.join("state.json");
23342        std::fs::create_dir_all(&source).unwrap();
23343        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23344        run_test_git(&source, &["init"]);
23345        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23346        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23347        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23348        run_test_git(&source, &["add", "seed.txt"]);
23349        run_test_git(&source, &["commit", "-m", "seed"]);
23350
23351        let catalog = active_registry().unwrap();
23352        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23353        let workspace = WorkspaceConfig::new(
23354            WorkspaceId::new("primary").unwrap(),
23355            &source,
23356        ).unwrap();
23357        let mut shared = NodeShared::new(
23358            handle,
23359            "fixture-token".to_owned(),
23360            NodeId::new("node-worktree-compensation").unwrap(),
23361            vec![workspace],
23362            vec![agent("claude")],
23363        );
23364        shared.state_path = Some(invalid_state_path);
23365
23366        let error = shared.create_worktree(
23367            WorkspaceId::new("primary").unwrap(),
23368            WorkspaceId::new("compensated").unwrap(),
23369            target.to_string_lossy().into_owned(),
23370            "gate4agent/compensated".to_owned(),
23371            Some("HEAD".to_owned()),
23372        ).await.unwrap_err();
23373
23374        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23375        assert!(!target.exists());
23376        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23377            .iter()
23378            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23379        std::fs::remove_dir_all(root).unwrap();
23380    }
23381
23382    #[test]
23383    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23384        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23385            NativeGitWorktreeSnapshot {
23386                path: path.to_owned(),
23387                head: "0123456789abcdef".to_owned(),
23388                branch: Some("refs/heads/test".to_owned()),
23389                is_bare,
23390                is_main,
23391                locked: false,
23392                lock_reason: None,
23393                prunable: false,
23394                prunable_reason: None,
23395                workspace_id: None,
23396            }
23397        }
23398
23399        let source = r"C:\repo";
23400        let selected = r"C:\repo-worktree";
23401        let listed = vec![
23402            worktree(source, true, false),
23403            worktree(selected, false, false),
23404        ];
23405        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23406
23407        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23408        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23409
23410        let bare = vec![
23411            worktree(source, true, false),
23412            worktree(selected, false, true),
23413        ];
23414        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23415        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23416
23417        let foreign_failure =
23418            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23419        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23420    }
23421
23422    fn terminal_frame(sequence: u64) -> TerminalFrame {
23423        TerminalFrame {
23424            sequence,
23425            size: gate4agent_types::TerminalSize {
23426                rows: 24,
23427                columns: 80,
23428            },
23429            cursor_row: 2,
23430            cursor_column: 3,
23431            contents: format!("frame-{sequence}"),
23432            formatted: vec![1, 2, 3],
23433            scrollback_formatted: Vec::new(),
23434            alternate_screen: false,
23435            mouse_protocol_enabled: false,
23436            mouse_protocol_encoding: Default::default(),
23437            produced_at_unix_ms: 0,
23438            screen_state: gate4agent_types::PtyScreenState::default(),
23439            bracketed_paste: None,
23440        }
23441    }
23442
23443    #[test]
23444    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23445        let shared = terminal_test_shared();
23446        let mut receiver = shared.terminal_event_tx.subscribe();
23447        let original = terminal_address(1);
23448
23449        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23450        let first = receiver.try_recv().unwrap();
23451        assert_eq!(first.len(), 1);
23452        assert!(matches!(
23453            &first[0].event,
23454            NodeEvent::TerminalFrame { address, frame }
23455                if address == &original && frame.sequence == 7
23456        ));
23457
23458        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23459        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23460        assert!(matches!(
23461            receiver.try_recv(),
23462            Err(broadcast::error::TryRecvError::Empty)
23463        ));
23464
23465        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23466        let advanced = receiver.try_recv().unwrap();
23467        assert_eq!(advanced[0].sequence, 2);
23468
23469        let resumed = terminal_address(2);
23470        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23471        let reset = receiver.try_recv().unwrap();
23472        assert_eq!(reset[0].sequence, 3);
23473        assert!(matches!(
23474            &reset[0].event,
23475            NodeEvent::TerminalFrame { address, frame }
23476                if address == &resumed && frame.sequence == 1
23477        ));
23478        shared.clear_terminal_frame_watermark(&resumed);
23479        assert!(!shared
23480            .terminal_frame_watermarks
23481            .lock()
23482            .unwrap_or_else(|poisoned| poisoned.into_inner())
23483            .contains_key(&resumed.session.instance_id));
23484    }
23485
23486    #[test]
23487    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23488        let shared = terminal_test_shared();
23489        let mut receiver = shared.terminal_event_tx.subscribe();
23490        let address = terminal_address(1);
23491        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23492
23493        for sequence in 1..=count {
23494            shared.publish_terminal_frame_candidates(vec![(
23495                address.clone(),
23496                terminal_frame(sequence),
23497            )]);
23498        }
23499
23500        let history = shared
23501            .history
23502            .lock()
23503            .unwrap_or_else(|poisoned| poisoned.into_inner());
23504        assert!(history.events.is_empty());
23505        assert_eq!(history.last_sequence, count);
23506        assert_eq!(history.replay_floor_sequence, 1);
23507        drop(history);
23508        let NodeResponse::Resync {
23509            event_sequence,
23510            oldest_available_sequence,
23511            events,
23512            ..
23513        } = shared.resync(0) else {
23514            unreachable!("resync helper returned another response")
23515        };
23516        assert_eq!(event_sequence, count);
23517        assert_eq!(oldest_available_sequence, 1);
23518        assert!(events.is_empty());
23519        assert!(matches!(
23520            resync_required_event(&shared).event,
23521            NodeEvent::ResyncRequired {
23522                oldest_available_sequence: 1,
23523            }
23524        ));
23525        assert!(matches!(
23526            receiver.try_recv(),
23527            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23528        ));
23529        let latest = receiver.try_recv().unwrap();
23530        assert_eq!(latest.len(), 1);
23531        assert_eq!(latest[0].sequence, count);
23532    }
23533
23534    #[test]
23535    fn real_durable_eviction_advances_authoritative_replay_floor() {
23536        let shared = terminal_test_shared();
23537        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23538            shared.publish(NodeEvent::ControllerChanged { controller: None });
23539        }
23540
23541        let NodeResponse::Resync {
23542            event_sequence,
23543            oldest_available_sequence,
23544            events,
23545            ..
23546        } = shared.resync(0) else {
23547            unreachable!("resync helper returned another response")
23548        };
23549        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23550        assert_eq!(oldest_available_sequence, 2);
23551        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23552        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23553        assert!(matches!(
23554            resync_required_event(&shared).event,
23555            NodeEvent::ResyncRequired {
23556                oldest_available_sequence: 2,
23557            }
23558        ));
23559    }
23560
23561    #[test]
23562    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23563        let shared = terminal_test_shared();
23564        let without_capability = terminal_event_subscription(&shared, false);
23565        let with_capability = terminal_event_subscription(&shared, true);
23566        assert!(without_capability.is_none());
23567        assert!(with_capability.is_some());
23568        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23569            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23570        }));
23571    }
23572
23573    #[test]
23574    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23575        let mut pending = Vec::new();
23576        let mut event_sequence = 0_u64;
23577        for frame_sequence in 1..=4 {
23578            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23579                event_sequence += 1;
23580                let mut address = terminal_address(1);
23581                address.session.instance_id = AgentInstanceId(instance);
23582                queue_connection_event(
23583                    &mut pending,
23584                    NodeEventEnvelope {
23585                        sequence: event_sequence,
23586                        event: NodeEvent::TerminalFrame {
23587                            address,
23588                            frame: terminal_frame(frame_sequence),
23589                        },
23590                    },
23591                    0,
23592                );
23593            }
23594        }
23595        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23596        pending.sort_unstable_by_key(|event| event.sequence);
23597
23598        let mut uninterrupted = pending.clone();
23599        let first_burst = connection_event_burst_len(uninterrupted.len());
23600        uninterrupted.drain(..first_burst);
23601        let second_burst = connection_event_burst_len(uninterrupted.len());
23602        uninterrupted.drain(..second_burst);
23603        assert!(uninterrupted.is_empty());
23604
23605        let (frame_tx, mut frame_rx) =
23606            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23607        frame_tx
23608            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23609                request_id: 77,
23610                request: NodeRequest::Snapshot,
23611            })))
23612            .unwrap();
23613        let burst_len = connection_event_burst_len(pending.len());
23614        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23615        pending.drain(..burst_len);
23616        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23617        assert!(matches!(
23618            frame_rx.try_recv(),
23619            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23620                request_id: 77,
23621                request: NodeRequest::Snapshot,
23622            })))
23623        ));
23624    }
23625
23626    #[test]
23627    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23628        let mut pending = Vec::new();
23629        for sequence in [7_u64, 8] {
23630            let mut address = terminal_address(1);
23631            address.session.instance_id = AgentInstanceId(sequence);
23632            queue_connection_event(
23633                &mut pending,
23634                NodeEventEnvelope {
23635                    sequence,
23636                    event: NodeEvent::TerminalFrame {
23637                        address,
23638                        frame: terminal_frame(sequence),
23639                    },
23640                },
23641                0,
23642            );
23643        }
23644        let mut discard_through = 0;
23645        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23646        assert!(pending.is_empty());
23647
23648        let replayed = NodeEventEnvelope {
23649            sequence: 8,
23650            event: NodeEvent::TerminalFrame {
23651                address: terminal_address(1),
23652                frame: terminal_frame(8),
23653            },
23654        };
23655        let advanced = NodeEventEnvelope {
23656            sequence: 9,
23657            event: NodeEvent::TerminalFrame {
23658                address: terminal_address(2),
23659                frame: terminal_frame(1),
23660            },
23661        };
23662        queue_connection_event_batch(
23663            &mut pending,
23664            &[replayed, advanced.clone()],
23665            discard_through,
23666        );
23667        assert_eq!(pending, vec![advanced]);
23668    }
23669
23670    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23671        ManagedSessionRecord {
23672            record_id: SessionRecordId::new(record_id).unwrap(),
23673            display_name: format!("{provider} record"),
23674            provider: agent(provider),
23675            mode: SessionMode::Pty,
23676            state: ManagedSessionState::Unavailable,
23677            workspace_id: WorkspaceId::new("primary").unwrap(),
23678            canonical_root: opaque_windows_path(
23679                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23680            ),
23681            provider_session: None,
23682            active_session: None,
23683            environment_profile: None,
23684            bundle: None,
23685            context_id: None,
23686            context: None,
23687            exported_context: None,
23688            task_binding: None,
23689            created_at_unix_ms: 1,
23690            updated_at_unix_ms: 1,
23691            last_error: None,
23692        }
23693    }
23694
23695    fn retention_test_record(
23696        workspace_id: &str,
23697        record_id: &str,
23698        state: ManagedSessionState,
23699        updated_at_unix_ms: u64,
23700    ) -> ManagedSessionRecord {
23701        ManagedSessionRecord {
23702            record_id: SessionRecordId::new(record_id).unwrap(),
23703            display_name: format!("{record_id} display"),
23704            provider: agent("claude"),
23705            mode: SessionMode::Pty,
23706            state,
23707            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23708            canonical_root: opaque_windows_path(
23709                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23710            ),
23711            provider_session: None,
23712            active_session: None,
23713            environment_profile: None,
23714            bundle: None,
23715            context_id: None,
23716            context: None,
23717            exported_context: None,
23718            task_binding: None,
23719            created_at_unix_ms: updated_at_unix_ms,
23720            updated_at_unix_ms,
23721            last_error: None,
23722        }
23723    }
23724
23725    /// Two workspaces of three `Unavailable` records each, staggered by
23726    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23727    /// `workspace-a` (all as old as that workspace's oldest real record --
23728    /// if the sweep's state gate ever leaked, these would be the first
23729    /// thing it wrongly retired). Ages, oldest to newest:
23730    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23731    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23732    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23733        vec![
23734            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23735            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23736            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23737            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23738            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23739            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23740            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23741            retention_test_record(
23742                "workspace-a",
23743                "decoy-pending",
23744                ManagedSessionState::IdentityPending,
23745                now - 500_000,
23746            ),
23747            retention_test_record(
23748                "workspace-a",
23749                "decoy-dormant",
23750                ManagedSessionState::Dormant,
23751                now - 500_000,
23752            ),
23753        ]
23754    }
23755
23756    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23757        let root = temporary_workspace_root("session-record-retention");
23758        std::fs::create_dir_all(&root).unwrap();
23759        let catalog = active_registry().unwrap();
23760        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23761        let workspace_a =
23762            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23763        let workspace_b =
23764            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23765        let mut shared = NodeShared::new(
23766            handle,
23767            "fixture-token".to_owned(),
23768            NodeId::new("node-retention-test").unwrap(),
23769            vec![workspace_a, workspace_b],
23770            vec![agent("claude")],
23771        );
23772        shared.state_path = Some(root.join("node-state-v10.json"));
23773        let now = unix_time_ms();
23774        for record in retention_fixture_records(now) {
23775            shared.insert_record(record).unwrap();
23776        }
23777        shared
23778            .persist_state_locked()
23779            .expect("fixture-only, empty-history persist against a fresh temp state path");
23780        (shared, runtime, root, now)
23781    }
23782
23783    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23784        shared
23785            .history
23786            .lock()
23787            .unwrap_or_else(|poisoned| poisoned.into_inner())
23788            .events
23789            .iter()
23790            .filter_map(|envelope| match &envelope.event {
23791                NodeEvent::SessionRecordRemoved { record_id } => {
23792                    Some(record_id.as_str().to_owned())
23793                }
23794                _ => None,
23795            })
23796            .collect()
23797    }
23798
23799    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23800        shared
23801            .session_records
23802            .lock()
23803            .unwrap_or_else(|poisoned| poisoned.into_inner())
23804            .records
23805            .keys()
23806            .map(|record_id| record_id.as_str().to_owned())
23807            .collect()
23808    }
23809
23810    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23811        values.iter().map(|value| (*value).to_owned()).collect()
23812    }
23813
23814    #[tokio::test]
23815    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23816        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23817        shared.session_record_retention = SessionRecordRetentionConfig {
23818            age_ms: 0,
23819            keep_per_workspace: 1,
23820        };
23821        shared.retire_unavailable_session_records().await;
23822
23823        assert_eq!(
23824            retention_remaining_record_ids(&shared),
23825            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23826        );
23827        assert_eq!(
23828            retention_removed_record_ids(&shared),
23829            owned(&["a1", "a2", "b1", "b2"]),
23830        );
23831        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23832
23833        drop(shared);
23834        drop(runtime);
23835        std::fs::remove_dir_all(root).unwrap();
23836    }
23837
23838    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23839    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23840    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23841    /// 200s old against a 250s threshold) even though it fails the keep-N
23842    /// test too, proving retirement needs BOTH, not either.
23843    #[tokio::test]
23844    async fn session_record_retention_both_set_retires_only_the_intersection() {
23845        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23846        shared.session_record_retention = SessionRecordRetentionConfig {
23847            age_ms: 250_000,
23848            keep_per_workspace: 1,
23849        };
23850        shared.retire_unavailable_session_records().await;
23851
23852        assert_eq!(
23853            retention_remaining_record_ids(&shared),
23854            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23855        );
23856        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23857        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23858
23859        drop(shared);
23860        drop(runtime);
23861        std::fs::remove_dir_all(root).unwrap();
23862    }
23863
23864    #[tokio::test]
23865    async fn session_record_retention_both_disabled_is_a_no_op() {
23866        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23867        shared.session_record_retention = SessionRecordRetentionConfig::default();
23868        shared.retire_unavailable_session_records().await;
23869
23870        assert_eq!(
23871            retention_remaining_record_ids(&shared),
23872            owned(&[
23873                "a1", "a2", "a3", "b1", "b2", "b3",
23874                "decoy-live", "decoy-pending", "decoy-dormant",
23875            ]),
23876        );
23877        assert!(retention_removed_record_ids(&shared).is_empty());
23878        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23879
23880        drop(shared);
23881        drop(runtime);
23882        std::fs::remove_dir_all(root).unwrap();
23883    }
23884
23885    #[test]
23886    fn session_record_context_export_selects_one_exact_history_identity() {
23887        let candidates = vec![
23888            HistoryCandidateSummary {
23889                id: "candidate-other".to_owned(),
23890                session_id_hint: "provider-other".to_owned(),
23891                modified_at_unix_ms: Some(1),
23892            },
23893            HistoryCandidateSummary {
23894                id: "candidate-exact".to_owned(),
23895                session_id_hint: "provider-exact".to_owned(),
23896                modified_at_unix_ms: Some(2),
23897            },
23898        ];
23899        assert_eq!(
23900            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23901            "candidate-exact",
23902        );
23903    }
23904
23905    #[test]
23906    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23907        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23908            id: id.to_owned(),
23909            session_id_hint: hint.to_owned(),
23910            modified_at_unix_ms: None,
23911        };
23912        let missing = select_history_candidate(
23913            &[candidate("candidate-other", "provider-other")],
23914            Some("provider-exact"),
23915        )
23916        .unwrap_err();
23917        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23918        let ambiguous = select_history_candidate(
23919            &[
23920                candidate("candidate-a", "provider-exact"),
23921                candidate("candidate-b", "provider-exact"),
23922            ],
23923            Some("provider-exact"),
23924        )
23925        .unwrap_err();
23926        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23927    }
23928
23929    /// The PTY-sidecar-observation shape (no fleet provider today, but
23930    /// capability-keyed rather than vendor-locked) never carries a verified
23931    /// `provider_session`; its record-bound export instead falls back to
23932    /// "exactly one discovered candidate" (see
23933    /// `export_context_pack_for_session_record_inner`). This is the same
23934    /// ambiguity refusal as the identity-filtered path, just without an
23935    /// identity to filter by.
23936    #[test]
23937    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23938        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23939            id: id.to_owned(),
23940            session_id_hint: hint.to_owned(),
23941            modified_at_unix_ms: None,
23942        };
23943        assert_eq!(
23944            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23945                .unwrap(),
23946            "candidate-sole",
23947        );
23948        let missing = select_history_candidate(&[], None).unwrap_err();
23949        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23950        let ambiguous = select_history_candidate(
23951            &[
23952                candidate("candidate-a", "sidecar-session-a"),
23953                candidate("candidate-b", "sidecar-session-b"),
23954            ],
23955            None,
23956        )
23957        .unwrap_err();
23958        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23959    }
23960
23961    #[test]
23962    fn session_record_context_export_revalidation_rejects_record_change() {
23963        let session = terminal_address(1);
23964        let identity = ProviderSessionIdentity {
23965            key: ProviderSessionKey::SessionId,
23966            id: "provider-exact".to_owned(),
23967            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23968        };
23969        let mut expected = record("claude", "record-context-export");
23970        expected.state = ManagedSessionState::Live;
23971        expected.provider_session = Some(identity.clone());
23972        expected.active_session = Some(session.clone());
23973        let binding = SessionBinding {
23974            workspace_id: session.workspace_id.clone(),
23975            generation: session.session.generation,
23976            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23977            pending_resume: None,
23978            record_id: Some(expected.record_id.clone()),
23979            managed_worktree_lease_id: None,
23980            environment_profile: None,
23981            bundle: None,
23982            context: None,
23983            materialization_id: None,
23984        };
23985        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23986        assert!(session_record_context_export_binding_is_exact(
23987            &expected,
23988            &expected,
23989            Some(&identity),
23990            &session,
23991            &binding,
23992            &expected.provider,
23993            &current_root,
23994            false,
23995        ));
23996        let mut changed = expected.clone();
23997        changed.updated_at_unix_ms += 1;
23998        assert!(!session_record_context_export_binding_is_exact(
23999            &expected,
24000            &changed,
24001            Some(&identity),
24002            &session,
24003            &binding,
24004            &expected.provider,
24005            &current_root,
24006            false,
24007        ));
24008        // A same-instant timestamp bump alone (state and active_session
24009        // otherwise unchanged) is not the clean-detachment transition
24010        // either, even with the reactive flag on: only the exact Live ->
24011        // Dormant + active_session cleared shape qualifies.
24012        assert!(!session_record_context_export_binding_is_exact(
24013            &expected,
24014            &changed,
24015            Some(&identity),
24016            &session,
24017            &binding,
24018            &expected.provider,
24019            &current_root,
24020            true,
24021        ));
24022        let mut detached = expected.clone();
24023        detached.state = ManagedSessionState::Dormant;
24024        detached.active_session = None;
24025        detached.updated_at_unix_ms += 1;
24026        assert!(!session_record_context_export_binding_is_exact(
24027            &expected,
24028            &detached,
24029            Some(&identity),
24030            &session,
24031            &binding,
24032            &expected.provider,
24033            &current_root,
24034            false,
24035        ));
24036        assert!(session_record_context_export_binding_is_exact(
24037            &expected,
24038            &detached,
24039            Some(&identity),
24040            &session,
24041            &binding,
24042            &expected.provider,
24043            &current_root,
24044            true,
24045        ));
24046    }
24047
24048    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24049    /// for a record whose provider never carries a verified identity (the
24050    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24051    /// declares one today): `identity` is `None` throughout, and the
24052    /// exactness check falls back to comparing `provider_session` itself,
24053    /// which must still be `None` on both sides.
24054    #[test]
24055    fn session_record_context_export_binding_is_exact_without_identity() {
24056        let session = terminal_address(1);
24057        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24058        expected.state = ManagedSessionState::Live;
24059        expected.active_session = Some(session.clone());
24060        let binding = SessionBinding {
24061            workspace_id: session.workspace_id.clone(),
24062            generation: session.session.generation,
24063            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24064            pending_resume: None,
24065            record_id: Some(expected.record_id.clone()),
24066            managed_worktree_lease_id: None,
24067            environment_profile: None,
24068            bundle: None,
24069            context: None,
24070            materialization_id: None,
24071        };
24072        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24073        assert!(session_record_context_export_binding_is_exact(
24074            &expected,
24075            &expected,
24076            None,
24077            &session,
24078            &binding,
24079            &expected.provider,
24080            &current_root,
24081            false,
24082        ));
24083        // A record that gained an identity between the initial lookup and
24084        // this recheck no longer matches the `None` this shape expects to
24085        // keep seeing -- treated the same as any other mid-export drift.
24086        let mut drifted = expected.clone();
24087        drifted.provider_session = Some(ProviderSessionIdentity {
24088            key: ProviderSessionKey::SessionId,
24089            id: "unexpected-identity".to_owned(),
24090            transcript_path: None,
24091        });
24092        assert!(!session_record_context_export_binding_is_exact(
24093            &expected,
24094            &drifted,
24095            None,
24096            &session,
24097            &binding,
24098            &expected.provider,
24099            &current_root,
24100            false,
24101        ));
24102    }
24103
24104    #[test]
24105    fn session_record_context_export_preflight_requires_live_usable_source() {
24106        let mut source = record("claude", "record-context-export-preflight");
24107        source.state = ManagedSessionState::Live;
24108        assert!(session_record_context_export_source_is_usable(
24109            &source,
24110            &SessionStatus::Running,
24111            false,
24112        ));
24113        assert!(session_record_context_export_source_is_usable(
24114            &source,
24115            &SessionStatus::Exited { exit_code: Some(0) },
24116            false,
24117        ));
24118
24119        source.state = ManagedSessionState::Dormant;
24120        assert!(!session_record_context_export_source_is_usable(
24121            &source,
24122            &SessionStatus::Running,
24123            false,
24124        ));
24125        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24126        // the only caller that may treat a Dormant, cleanly-exited record as
24127        // still usable — the on-demand path above never does.
24128        assert!(session_record_context_export_source_is_usable(
24129            &source,
24130            &SessionStatus::Exited { exit_code: Some(0) },
24131            true,
24132        ));
24133        assert!(!session_record_context_export_source_is_usable(
24134            &source,
24135            &SessionStatus::Exited { exit_code: Some(0) },
24136            false,
24137        ));
24138        source.state = ManagedSessionState::Live;
24139        assert!(!session_record_context_export_source_is_usable(
24140            &source,
24141            &SessionStatus::Starting,
24142            false,
24143        ));
24144    }
24145
24146    #[test]
24147    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24148        let catalog = active_registry().unwrap();
24149        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24150        let workspace = WorkspaceConfig::new(
24151            WorkspaceId::new("primary").unwrap(),
24152            std::env::current_dir().unwrap(),
24153        )
24154        .unwrap();
24155        let shared = NodeShared::new(
24156            handle,
24157            "fixture-token".to_owned(),
24158            NodeId::new("node-context-export-conflict").unwrap(),
24159            vec![workspace],
24160            vec![agent("claude")],
24161        );
24162        let history = HistorySessionRecord {
24163            session_id: "provider-exact".to_owned(),
24164            title: None,
24165            cwd: None,
24166            model: None,
24167            message_count: 1,
24168            completed_turn_count: None,
24169            total_tokens: 0,
24170            messages: vec![gate4agent_types::HistoryMessageRecord {
24171                role: gate4agent_types::HistoryMessageRole::User,
24172                text: "bounded context".to_owned(),
24173            }],
24174        };
24175        let pack = NodeContextPack::export(
24176            ContextPackLineageReceipt {
24177                source_node_id: shared.node_id.clone(),
24178                source_session: terminal_address(1),
24179                source_provider: agent("claude"),
24180            },
24181            &history,
24182        )
24183        .unwrap();
24184        let context_id = pack.receipt().id.clone();
24185        let session = terminal_address(1);
24186        let identity = ProviderSessionIdentity {
24187            key: ProviderSessionKey::SessionId,
24188            id: history.session_id.clone(),
24189            transcript_path: None,
24190        };
24191        let mut expected = record("claude", "record-context-export-final-conflict");
24192        expected.state = ManagedSessionState::Live;
24193        expected.provider_session = Some(identity.clone());
24194        expected.active_session = Some(session.clone());
24195        let mut changed = expected.clone();
24196        changed.updated_at_unix_ms += 1;
24197        shared.insert_record(changed).unwrap();
24198
24199        let error = shared
24200            .commit_context_pack_for_session_record(
24201                &expected,
24202                Some(&identity),
24203                &session,
24204                "candidate-exact",
24205                &history,
24206                pack,
24207                false,
24208            )
24209            .unwrap_err();
24210
24211        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24212        assert!(shared
24213            .context_catalog
24214            .read()
24215            .unwrap_or_else(|poisoned| poisoned.into_inner())
24216            .get(&context_id)
24217            .is_none());
24218    }
24219
24220    #[tokio::test]
24221    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24222        let root = temporary_workspace_root("session-task-binding");
24223        std::fs::create_dir_all(&root).unwrap();
24224        let state_path = root.join("node-state.json");
24225        let catalog = active_registry().unwrap();
24226        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24227        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24228        let node_id = NodeId::new("node-1").unwrap();
24229        let mut shared = NodeShared::new(
24230            handle,
24231            "fixture-token".to_owned(),
24232            node_id.clone(),
24233            vec![workspace],
24234            vec![agent("claude")],
24235        );
24236        shared.state_path = Some(state_path.clone());
24237        let record = record("claude", "sr-task");
24238        let record_id = record.record_id.clone();
24239        shared.insert_record(record).unwrap();
24240        shared.persist_state().unwrap();
24241        shared
24242            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24243            .unwrap();
24244
24245        let minted = process_request_inner(
24246            &shared,
24247            77,
24248            ClientRole::Operator,
24249            NodeRequest::SetSessionTask {
24250                record_id: record_id.clone(),
24251                expected_revision: 0,
24252                target: SessionTaskTargetV1::New,
24253            },
24254        )
24255        .await
24256        .unwrap();
24257        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24258            panic!("session task mutation returned another response");
24259        };
24260        let minted_binding = minted.task_binding.clone().unwrap();
24261        let minted_task_id = minted_binding.task_id.clone().unwrap();
24262        assert_eq!(minted_binding.revision, 1);
24263        assert!(minted_task_id.as_str().starts_with("task-"));
24264        assert_eq!(minted_task_id.as_str().len(), 29);
24265
24266        let event_count = shared.current_sequence();
24267        let repeated = process_request_inner(
24268            &shared,
24269            77,
24270            ClientRole::Operator,
24271            NodeRequest::SetSessionTask {
24272                record_id: record_id.clone(),
24273                expected_revision: 1,
24274                target: SessionTaskTargetV1::Existing {
24275                    task_id: minted_task_id.clone(),
24276                },
24277            },
24278        )
24279        .await
24280        .unwrap();
24281        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24282        assert_eq!(shared.current_sequence(), event_count);
24283
24284        let stale = process_request_inner(
24285            &shared,
24286            77,
24287            ClientRole::Operator,
24288            NodeRequest::SetSessionTask {
24289                record_id: record_id.clone(),
24290                expected_revision: 0,
24291                target: SessionTaskTargetV1::Existing {
24292                    task_id: minted_task_id,
24293                },
24294            },
24295        )
24296        .await
24297        .unwrap_err();
24298        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24299
24300        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24301        let assigned = process_request_inner(
24302            &shared,
24303            77,
24304            ClientRole::Operator,
24305            NodeRequest::SetSessionTask {
24306                record_id: record_id.clone(),
24307                expected_revision: 1,
24308                target: SessionTaskTargetV1::Existing {
24309                    task_id: assigned_id.clone(),
24310                },
24311            },
24312        )
24313        .await
24314        .unwrap();
24315        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24316            panic!("session task assignment returned another response");
24317        };
24318        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24319        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24320
24321        let cleared = process_request_inner(
24322            &shared,
24323            77,
24324            ClientRole::Operator,
24325            NodeRequest::SetSessionTask {
24326                record_id: record_id.clone(),
24327                expected_revision: 2,
24328                target: SessionTaskTargetV1::Clear,
24329            },
24330        )
24331        .await
24332        .unwrap();
24333        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24334            panic!("session task clear returned another response");
24335        };
24336        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24337        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24338        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24339        assert_eq!(
24340            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24341            cleared.updated_at_unix_ms,
24342        );
24343
24344        let persisted = std::fs::read(&state_path).unwrap();
24345        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24346        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24347        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24348        std::fs::remove_dir_all(root).unwrap();
24349    }
24350
24351    #[test]
24352    fn session_task_capability_strips_snapshots_events_and_replies() {
24353        let mut bound = record("claude", "sr-task-projection");
24354        bound.updated_at_unix_ms = 2;
24355        bound.task_binding = Some(SessionTaskBindingV1 {
24356            revision: 1,
24357            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24358            changed_at_unix_ms: 2,
24359        });
24360        let request = NodeRequest::SetSessionTask {
24361            record_id: bound.record_id.clone(),
24362            expected_revision: 1,
24363            target: SessionTaskTargetV1::Clear,
24364        };
24365        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24366            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24367        }));
24368        assert!(!request_uses_unnegotiated_capability(
24369            &request,
24370            &baseline_capabilities().unwrap(),
24371        ));
24372        assert!(request_uses_unnegotiated_capability(&request, &[]));
24373
24374        let mut snapshot = terminal_test_shared().snapshot();
24375        snapshot.session_records.push(bound.clone());
24376        let mut reply = ResponseEnvelope {
24377            request_id: 1,
24378            result: Ok(NodeResponse::Resync {
24379                event_sequence: 1,
24380                oldest_available_sequence: 1,
24381                snapshot,
24382                events: vec![NodeEventEnvelope {
24383                    sequence: 1,
24384                    event: NodeEvent::SessionRecordUpserted {
24385                        record: bound.clone(),
24386                    },
24387                }],
24388            }),
24389        };
24390        project_response_without_session_task_binding(&mut reply);
24391        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24392            panic!("task projection changed the response kind");
24393        };
24394        assert!(snapshot.session_records[0].task_binding.is_none());
24395        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24396            panic!("task projection changed the event kind");
24397        };
24398        assert!(record.task_binding.is_none());
24399
24400        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24401            sequence: 2,
24402            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24403        });
24404        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24405            panic!("task event projection changed the event kind");
24406        };
24407        assert!(record.task_binding.is_none());
24408
24409        let mut direct = ResponseEnvelope {
24410            request_id: 2,
24411            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24412        };
24413        project_response_without_session_task_binding(&mut direct);
24414        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24415            panic!("task reply projection changed the response kind");
24416        };
24417        assert!(record.task_binding.is_none());
24418    }
24419
24420    #[test]
24421    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24422        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24423        let workspace = WorkspaceConfig::new(
24424            WorkspaceId::new("primary").unwrap(),
24425            std::env::current_dir().unwrap(),
24426        )
24427        .unwrap();
24428        let config = NodeServerConfig::new(
24429            DEFAULT_NODE_ENDPOINT,
24430            "fixture-token",
24431            NodeId::new("node-1").unwrap(),
24432            [workspace],
24433        )
24434        .unwrap();
24435        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24436    }
24437
24438    #[cfg(all(feature = "fixture", windows))]
24439    #[test]
24440    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24441        let root = temporary_workspace_root("provider-bundle-argv-hold");
24442        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24443        std::fs::create_dir_all(&root).unwrap();
24444        std::fs::create_dir_all(&outside).unwrap();
24445        let proof_path = root.join("provider.proof");
24446        let release_signal = root.join("release.signal");
24447        let config = NodeServerConfig::new(
24448            format!(
24449                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24450                std::process::id(),
24451            ),
24452            "fixture-token",
24453            NodeId::new("provider-bundle-hold-node").unwrap(),
24454            [WorkspaceConfig::new(
24455                WorkspaceId::new("primary").unwrap(),
24456                std::env::current_dir().unwrap(),
24457            )
24458            .unwrap()],
24459        )
24460        .unwrap()
24461        .with_spawn_profiles(SpawnProfileRegistry::new([
24462            crate::protocol::SpawnProfileDefaults {
24463                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24464                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24465                    .unwrap(),
24466                provider: agent("kimi"),
24467                mode: SessionMode::Pty,
24468                terminal_size: gate4agent_types::TerminalSize {
24469                    rows: 24,
24470                    columns: 80,
24471                },
24472                prompt: None,
24473                bundle_id: None,
24474                context_id: None,
24475                environment_profile_id: None,
24476            },
24477        ])
24478        .unwrap());
24479        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24480            config,
24481            agent("kimi"),
24482            proof_path.clone(),
24483            release_signal.clone(),
24484        )
24485        .unwrap();
24486        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24487
24488        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24489            agent("kimi"),
24490            proof_path.clone(),
24491            Some(release_signal.clone()),
24492        )
24493        .unwrap();
24494        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24495        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24496        let release_wait = wrapper
24497            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24498            .unwrap();
24499        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24500        assert!(proof_write < release_wait);
24501        assert!(release_wait < fixture_ready);
24502        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24503        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24504        assert_eq!(
24505            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24506            &[
24507                proof_path.to_string_lossy().into_owned(),
24508                release_signal.to_string_lossy().into_owned(),
24509            ],
24510        );
24511
24512        let error = NodeServer::provider_bundle_argv_fixture_spec(
24513            agent("kimi"),
24514            proof_path,
24515            Some(outside.join("release.signal")),
24516        )
24517        .unwrap_err();
24518        assert!(matches!(
24519            error,
24520            NodeServerError::Registry(message)
24521                if message == "provider bundle argv release path must share the proof parent"
24522        ));
24523        drop(server);
24524        std::fs::remove_dir_all(root).unwrap();
24525        std::fs::remove_dir_all(outside).unwrap();
24526    }
24527
24528    #[cfg(feature = "fixture")]
24529    #[test]
24530    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24531        let root = temporary_workspace_root("context-fixture-registry");
24532        std::fs::create_dir_all(&root).unwrap();
24533        let proof_path = root.join("context.proof");
24534        let config = NodeServerConfig::new(
24535            format!(
24536                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24537                std::process::id(),
24538            ),
24539            "fixture-token",
24540            NodeId::new("context-fixture-node").unwrap(),
24541            [WorkspaceConfig::new(
24542                WorkspaceId::new("primary").unwrap(),
24543                std::env::current_dir().unwrap(),
24544            )
24545            .unwrap()],
24546        )
24547        .unwrap();
24548        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24549        let mut enabled = server
24550            .shared
24551            .enabled_providers
24552            .iter()
24553            .map(AgentId::as_str)
24554            .collect::<Vec<_>>();
24555        enabled.sort_unstable();
24556        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24557        for provider in &server.shared.enabled_providers {
24558            assert!(server
24559                .shared
24560                .provider_adapter_contracts
24561                .iter()
24562                .any(|contract| {
24563                    &contract.provider == provider
24564                        && contract.family == AdapterFamily::History
24565                }));
24566        }
24567        drop(server);
24568        std::fs::remove_dir_all(root).unwrap();
24569    }
24570
24571    #[cfg(feature = "fixture")]
24572    #[test]
24573    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24574        let root = temporary_workspace_root("monitoring-context-fixture");
24575        std::fs::create_dir_all(&root).unwrap();
24576        let proof_path = root.join("context.proof");
24577        let standard = NodeServer::context_pack_fixture_catalog(
24578            proof_path.clone(),
24579            None,
24580            false,
24581        ).unwrap();
24582        let monitoring = NodeServer::context_pack_fixture_catalog(
24583            proof_path.clone(),
24584            None,
24585            true,
24586        ).unwrap();
24587        let claude = AgentId::new("claude").unwrap();
24588        let standard_claude = standard.get(&claude).unwrap();
24589        let monitoring_claude = monitoring.get(&claude).unwrap();
24590        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24591        // standard nor the monitoring fixture declares a hook adapter
24592        // anymore.
24593        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24594        assert_eq!(
24595            monitoring_claude.capabilities.adapters.history,
24596            standard_claude.capabilities.adapters.history,
24597        );
24598        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24599        let codex = AgentId::new("codex").unwrap();
24600        let standard_codex = standard.get(&codex).unwrap();
24601        let monitoring_codex = monitoring.get(&codex).unwrap();
24602        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24603        assert_eq!(
24604            monitoring_codex.capabilities.adapters.history,
24605            standard_codex.capabilities.adapters.history,
24606        );
24607        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24608        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24609            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24610            .unwrap();
24611        for required in [
24612            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24613            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24614            "@('role', 'text')",
24615            "g4a-private-provider-session-canary",
24616            "private-provider-login-identity",
24617            "$rawProof",
24618            "$env:GATE4AGENT_HOOK_URL",
24619            "g4a-private-provider-session-canary",
24620        ] {
24621            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24622        }
24623        for provider in ["grok", "kimi"] {
24624            let provider = AgentId::new(provider).unwrap();
24625            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24626        }
24627
24628        let config = NodeServerConfig::new(
24629            format!(
24630                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24631                std::process::id(),
24632            ),
24633            "fixture-token",
24634            NodeId::new("monitoring-context-fixture-node").unwrap(),
24635            [WorkspaceConfig::new(
24636                WorkspaceId::new("primary").unwrap(),
24637                std::env::current_dir().unwrap(),
24638            )
24639            .unwrap()],
24640        )
24641        .unwrap();
24642        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24643        assert!(server.shared.fixture_semantic_hook_policy);
24644        drop(server);
24645        std::fs::remove_dir_all(root).unwrap();
24646    }
24647
24648    #[cfg(feature = "fixture")]
24649    #[test]
24650    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24651        let root = temporary_workspace_root("kimi-context-only-fixture");
24652        std::fs::create_dir_all(&root).unwrap();
24653        let proof_path = root.join("context.proof");
24654        let standard = NodeServer::context_pack_fixture_catalog(
24655            proof_path.clone(),
24656            None,
24657            false,
24658        ).unwrap();
24659        let kimi = AgentId::new("kimi").unwrap();
24660        let context_only = NodeServer::context_pack_fixture_catalog(
24661            proof_path,
24662            Some(&kimi),
24663            false,
24664        )
24665        .unwrap();
24666
24667        let codex = AgentId::new("codex").unwrap();
24668        assert_eq!(
24669            standard.get(&codex).unwrap().launch.fixed_args,
24670            context_only.get(&codex).unwrap().launch.fixed_args,
24671        );
24672        let script = context_only
24673            .get(&kimi)
24674            .unwrap()
24675            .launch
24676            .fixed_args
24677            .iter()
24678            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24679            .expect("Kimi context-only validation script is missing");
24680        for required in [
24681            "$bundleArgs.Count -ne 0",
24682            "GATE4AGENT_CONTEXT_ROOT",
24683            "context-pack.json",
24684            "$entries.Count -ne 1",
24685            "g4a-context-pack-v1",
24686            "$document.PSObject.Properties['cwd']",
24687            "'claude', 'codex', 'grok', 'kimi'",
24688            "$hasUser",
24689            "$hasAssistant",
24690            "SHA256",
24691            "context-only",
24692        ] {
24693            assert!(script.contains(required), "missing validation: {required}");
24694        }
24695        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24696            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24697        }
24698        assert!(!standard
24699            .get(&kimi)
24700            .unwrap()
24701            .launch
24702            .fixed_args
24703            .iter()
24704            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24705
24706        std::fs::remove_dir_all(root).unwrap();
24707    }
24708
24709    #[cfg(all(feature = "fixture", windows))]
24710    #[tokio::test]
24711    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24712        let root = temporary_workspace_root("kimi-context-only-spawn");
24713        let workspace_root = root.join("workspace");
24714        std::fs::create_dir_all(&workspace_root).unwrap();
24715        let materialization_root = root.join("private-materializations");
24716        let proof_path = root.join("context.proof");
24717        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24718        let workspace_id = WorkspaceId::new("primary").unwrap();
24719        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24720        let endpoint = format!(
24721            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24722            std::process::id(),
24723            unix_time_ms(),
24724        );
24725        let spawn_profiles = SpawnProfileRegistry::new([
24726            crate::protocol::SpawnProfileDefaults {
24727                profile_id: profile_id.clone(),
24728                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24729                provider: agent("kimi"),
24730                mode: SessionMode::Pty,
24731                terminal_size: gate4agent_types::TerminalSize {
24732                    rows: 30,
24733                    columns: 120,
24734                },
24735                prompt: None,
24736                bundle_id: None,
24737                context_id: None,
24738                environment_profile_id: None,
24739            },
24740        ])
24741        .unwrap();
24742        let mut config = NodeServerConfig::new(
24743            &endpoint,
24744            "fixture-token",
24745            node_id.clone(),
24746            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24747        )
24748        .unwrap()
24749        .with_spawn_profiles(spawn_profiles)
24750        .with_session_environment_materialization(
24751            materialization_root,
24752            Arc::new(FixtureSecretResolver {
24753                deny: Arc::new(AtomicBool::new(false)),
24754            }),
24755        )
24756        .unwrap();
24757        config.fixture_raw_pty_runtime = true;
24758        let server = NodeServer::new_context_only_proof_fixture(
24759            config,
24760            agent("kimi"),
24761            proof_path.clone(),
24762        )
24763        .unwrap();
24764        let shared = Arc::clone(&server.shared);
24765        let shutdown = server.shutdown_handle();
24766
24767        let history = HistorySessionRecord {
24768            session_id: "context-only-source-history".to_owned(),
24769            title: None,
24770            cwd: None,
24771            model: None,
24772            message_count: 2,
24773            completed_turn_count: None,
24774            total_tokens: 0,
24775            messages: vec![
24776                gate4agent_types::HistoryMessageRecord {
24777                    role: gate4agent_types::HistoryMessageRole::User,
24778                    text: "continue the bounded parent run through native C2".to_owned(),
24779                },
24780                gate4agent_types::HistoryMessageRecord {
24781                    role: gate4agent_types::HistoryMessageRole::Assistant,
24782                    text: "the exact parent context is ready for the child run".to_owned(),
24783                },
24784            ],
24785        };
24786        let pack = NodeContextPack::export(
24787            ContextPackLineageReceipt {
24788                source_node_id: node_id.clone(),
24789                source_session: SessionAddress {
24790                    workspace_id: workspace_id.clone(),
24791                    session: SessionKey {
24792                        instance_id: AgentInstanceId(71),
24793                        generation: SessionGeneration(1),
24794                    },
24795                },
24796                source_provider: agent("codex"),
24797            },
24798            &history,
24799        )
24800        .unwrap();
24801        let expected_context_hash = digest(&SHA256, pack.bytes())
24802            .as_ref()
24803            .iter()
24804            .map(|byte| format!("{byte:02x}"))
24805            .collect::<String>();
24806        let context = shared
24807            .context_catalog
24808            .write()
24809            .unwrap_or_else(|poisoned| poisoned.into_inner())
24810            .insert(pack)
24811            .unwrap();
24812        let spec = SpawnSpec {
24813            target: crate::protocol::SpawnTarget {
24814                node_id,
24815                workspace_id: workspace_id.clone(),
24816                worktree_id: None,
24817            },
24818            profile_id,
24819            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24820                "target-kimi-r1",
24821            ).unwrap(),
24822            overrides: crate::protocol::SpawnOverrides {
24823                context_id: crate::protocol::SpawnOverride::Set {
24824                    value: context.id.clone(),
24825                },
24826                ..crate::protocol::SpawnOverrides::default()
24827            },
24828            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24829            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24830            required_capabilities: SpawnRequiredCapabilities::default(),
24831        };
24832        let resolved = match shared.resolve_spawn_spec(&spec) {
24833            Ok(resolved) => resolved,
24834            Err(error) => panic!(
24835                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24836                error.code,
24837                error.message,
24838            ),
24839        };
24840        let resolved_context = match shared.resolve_context(&resolved) {
24841            Ok(Some(resolved_context)) => resolved_context,
24842            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24843            Err(error) => panic!(
24844                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24845                error.code,
24846                error.message,
24847            ),
24848        };
24849        assert_eq!(resolved_context, context);
24850        let runtime_policy = match shared
24851            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24852            .await
24853        {
24854            Ok(runtime_policy) => runtime_policy,
24855            Err(error) => panic!(
24856                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24857                error.code,
24858                error.message,
24859            ),
24860        };
24861        assert!(runtime_policy.raw_pty_lifecycle);
24862
24863        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24864        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24865            agent("kimi"),
24866            TransportKind::Pty,
24867            vec![EnvMutation {
24868                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24869                value: Some(OsString::from("must-remain-rejected")),
24870            }],
24871        )
24872        .unwrap();
24873        assert_eq!(
24874            launch_control
24875                .install_native_launch_environment_overlay(
24876                    AgentInstanceId(72),
24877                    generic_overlay,
24878                )
24879                .unwrap_err(),
24880            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24881        );
24882
24883        let diagnostic_address = SessionAddress {
24884            workspace_id: workspace_id.clone(),
24885            session: SessionKey {
24886                instance_id: AgentInstanceId(73),
24887                generation: SessionGeneration(1),
24888            },
24889        };
24890        let (diagnostic_overlay, diagnostic_materialization) = match shared
24891            .prepare_session_materialization(
24892                &diagnostic_address,
24893                &agent("kimi"),
24894                SessionMode::Pty,
24895                None,
24896                None,
24897                Some(&resolved_context),
24898                None,
24899            )
24900        {
24901            Ok(Some(prepared)) => prepared,
24902            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24903            Err(error) => panic!(
24904                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24905                error.code,
24906                error.message,
24907            ),
24908        };
24909        let diagnostic_overlay = diagnostic_overlay
24910            .expect("Kimi context-only materialization omitted its environment overlay");
24911        assert!(matches!(
24912            &diagnostic_overlay,
24913            PreparedNativeLaunchOverlay::Environment(_),
24914        ));
24915        match shared.install_prepared_launch_overlay(
24916            diagnostic_address.session.instance_id,
24917            diagnostic_overlay,
24918        ) {
24919            Ok(None) => {}
24920            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24921            Err(error) => panic!(
24922                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24923                error.code,
24924                error.message,
24925            ),
24926        }
24927        assert!(launch_control.clear_native_instance_launch_overlay(
24928            diagnostic_address.session.instance_id,
24929        ));
24930        drop(diagnostic_materialization);
24931        assert!(shared
24932            .materializations
24933            .lock()
24934            .unwrap_or_else(|poisoned| poisoned.into_inner())
24935            .is_empty());
24936
24937        let node_task = tokio::spawn(server.run());
24938        let ready_client = timeout(Duration::from_secs(5), async {
24939            loop {
24940                match gate4agent_node_wire::LocalNodeClient::connect(
24941                    &endpoint,
24942                    &shared.node_id,
24943                    ClientRole::Observer,
24944                    "fixture-token",
24945                )
24946                .await
24947                {
24948                    Ok(client) => break client,
24949                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24950                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24951                }
24952            }
24953        })
24954        .await
24955        .expect("Kimi context-only Node did not become ready");
24956        assert_eq!(
24957            ready_client
24958                .hello()
24959                .snapshot
24960                .provider_runtime_statuses
24961                .iter()
24962                .find(|status| status.provider().as_str() == "kimi")
24963                .expect("Kimi runtime status is missing")
24964                .mode(),
24965            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24966        );
24967        drop(ready_client);
24968
24969        let receipt = match shared.spawn_from_spec(spec).await {
24970            Ok(receipt) => receipt,
24971            Err(error) => panic!(
24972                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24973                error.code,
24974                error.message,
24975            ),
24976        };
24977        assert_eq!(receipt.provider, agent("kimi"));
24978        assert_eq!(receipt.context.as_ref(), Some(&context));
24979        assert!(receipt.context_binding_is_valid());
24980        timeout(Duration::from_secs(5), async {
24981            while !proof_path.is_file() {
24982                sleep(Duration::from_millis(10)).await;
24983            }
24984        })
24985        .await
24986        .expect("Kimi context-only fixture did not write its proof");
24987
24988        let proof = std::fs::read_to_string(&proof_path).unwrap();
24989        let lines = proof.lines().collect::<Vec<_>>();
24990        assert_eq!(lines.len(), 7);
24991        assert_eq!(lines[0], "context-only");
24992        assert!(Path::new(lines[1]).is_absolute());
24993        assert_ne!(
24994            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24995            std::fs::canonicalize(&workspace_root).unwrap(),
24996        );
24997        assert_eq!(
24998            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24999            std::fs::canonicalize(&workspace_root).unwrap(),
25000        );
25001        assert_eq!(lines[3], expected_context_hash);
25002        assert_eq!(lines[4], "g4a-context-pack-v1");
25003        assert_eq!(lines[5], "codex");
25004        assert_eq!(lines[6], "2");
25005        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25006            session.instance_id == receipt.session.session.instance_id
25007                && session.agent_id.as_str() == "kimi"
25008        }));
25009
25010        shutdown.request_shutdown().await.unwrap();
25011        timeout(Duration::from_secs(5), node_task)
25012            .await
25013            .unwrap()
25014            .unwrap()
25015            .unwrap();
25016        drop(shutdown);
25017        drop(shared);
25018        std::fs::remove_dir_all(root).unwrap();
25019    }
25020
25021    #[cfg(feature = "fixture")]
25022    #[test]
25023    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25024        let root = temporary_workspace_root("clean-exit-constructor");
25025        std::fs::create_dir_all(&root).unwrap();
25026        let config = construction_test_config("clean-exit-constructor")
25027            .with_state_path(root.join("node-state.json"))
25028            .unwrap();
25029        let server = NodeServer::new_clean_exit_fixture(
25030            config,
25031            root.clone(),
25032            root.join("started.marker"),
25033            root.join("release.signal"),
25034        )
25035        .unwrap();
25036        assert_eq!(
25037            server
25038                .shared
25039                .enabled_providers
25040                .iter()
25041                .map(AgentId::as_str)
25042                .collect::<Vec<_>>(),
25043            ["claude"],
25044        );
25045        drop(server);
25046        std::fs::remove_dir_all(root).unwrap();
25047    }
25048
25049    #[test]
25050    fn production_registry_provider_contract_manifest_is_exact() {
25051        let registry = active_registry().unwrap();
25052        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25053        assert_eq!(
25054            providers
25055                .iter()
25056                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25057                .collect::<Vec<_>>(),
25058            vec![
25059                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25060                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25061                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25062                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25063            ]
25064        );
25065        assert_eq!(
25066            adapters
25067                .iter()
25068                .map(|contract| (
25069                    contract.provider.as_str(),
25070                    contract.family,
25071                    contract.adapter_id.as_str(),
25072                    contract.revision.as_str(),
25073                ))
25074                .collect::<Vec<_>>(),
25075            vec![
25076                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25077                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25078                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25079                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25080                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25081                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25082                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25083                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25084                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25085                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25086                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25087                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25088                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25089                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25090                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25091                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25092                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25093                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25094                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25095                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25096                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25097                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25098                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25099            ]
25100        );
25101    }
25102
25103    fn construction_test_config(name: &str) -> NodeServerConfig {
25104        let workspace = WorkspaceConfig::new(
25105            WorkspaceId::new("primary").unwrap(),
25106            std::env::current_dir().unwrap(),
25107        )
25108        .unwrap();
25109        NodeServerConfig::new(
25110            format!(r"\\.\pipe\gate4agent-{name}"),
25111            "fixture-token",
25112            NodeId::new(name).unwrap(),
25113            [workspace],
25114        )
25115        .unwrap()
25116    }
25117
25118    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25119        let adapter = AdapterBinding::new(
25120            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25121            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25122            gate4agent_types::AdapterVerification::SyntheticFixture,
25123        )
25124        .unwrap();
25125        let agent_ids = ["claude", "codex", "kimi"]
25126            .into_iter()
25127            .map(|agent_id| AgentId::new(agent_id).unwrap())
25128            .collect::<Vec<_>>();
25129        let adapters = gate4agent_catalog::AdapterRegistry::new(
25130            [
25131                AdapterFamily::PtySemantic,
25132                AdapterFamily::Pipe,
25133                AdapterFamily::OneShot,
25134                AdapterFamily::Acp,
25135                AdapterFamily::Hook,
25136                AdapterFamily::ManagedHook,
25137                AdapterFamily::History,
25138                AdapterFamily::Resume,
25139                AdapterFamily::SessionOptions,
25140                AdapterFamily::CapabilityProbe,
25141            ]
25142            .into_iter()
25143            .map(|family| gate4agent_catalog::AdapterDescriptor {
25144                family,
25145                binding: adapter.clone(),
25146                agents: agent_ids.clone(),
25147            }),
25148        )
25149        .unwrap();
25150        let specs = agent_ids
25151            .iter()
25152            .map(|agent_id| {
25153                let agent_id = agent_id.as_str();
25154                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25155                spec.revision =
25156                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25157                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25158                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25159                    adapter: adapter.clone(),
25160                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25161                    launch_override: None,
25162                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25163                });
25164                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25165                    adapter: adapter.clone(),
25166                    launch_override: None,
25167                });
25168                spec.capabilities.adapters.hook = Some(adapter.clone());
25169                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25170                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25171                spec.capabilities.adapters.history = Some(adapter.clone());
25172                spec.capabilities.adapters.resume = Some(adapter.clone());
25173                spec.capabilities.adapters.session_options = Some(adapter.clone());
25174                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25175                spec
25176            })
25177            .collect::<Vec<_>>();
25178        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25179    }
25180
25181    /// The real fleet's manifest is accepted by a real server construction.
25182    ///
25183    /// The count is asserted against `active_registry` itself rather than a
25184    /// literal, so this stays a test that construction succeeds for whatever
25185    /// the fleet is -- which is the property worth guarding -- instead of a
25186    /// second place the fleet size is written down and has to be kept in
25187    /// step with the first.
25188    #[test]
25189    fn node_server_construction_accepts_the_production_manifest() {
25190        let registry = active_registry().unwrap();
25191        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25192        assert_eq!(providers.len(), registry.iter().count());
25193        assert!(!providers.is_empty());
25194        let server = NodeServer::new_with_registry(
25195            construction_test_config("manifest-production-bound"),
25196            registry,
25197        )
25198        .unwrap();
25199        drop(server);
25200    }
25201
25202    #[test]
25203    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25204        let registry = maximum_size_provider_manifest_registry();
25205        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25206        assert_eq!(providers.len(), 3);
25207        assert_eq!(adapters.len(), 30);
25208        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25209        let error = match NodeServer::new_with_registry(
25210            construction_test_config("manifest-handshake-overflow"),
25211            registry,
25212        ) {
25213            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25214            Err(error) => error,
25215        };
25216        assert!(matches!(
25217            error,
25218            NodeServerError::ProviderContractManifest(message)
25219                if message.contains("authentication binding length")
25220                    && message.contains("8192-byte limit")
25221        ));
25222    }
25223
25224    #[test]
25225    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25226        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25227        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25228        let registry = AgentRegistry::new([spec]).unwrap();
25229        assert!(matches!(
25230            provider_contract_manifest(&registry),
25231            Err(NodeServerError::ProviderContractManifest(message))
25232                if message.contains("exceeds the 128-byte limit")
25233        ));
25234    }
25235
25236    #[test]
25237    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25238        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25239        spec.revision = "invalid revision".to_owned();
25240        let registry = AgentRegistry::new([spec]).unwrap();
25241        assert!(matches!(
25242            provider_contract_manifest(&registry),
25243            Err(NodeServerError::ProviderContractManifest(message))
25244                if message.contains("bounded lowercase ASCII")
25245        ));
25246    }
25247
25248    #[test]
25249    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25250        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25251        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25252        let conflicting_binding = builtin_registry()
25253            .get_by_id("codex")
25254            .unwrap()
25255            .capabilities
25256            .adapters
25257            .one_shot
25258            .clone()
25259            .unwrap();
25260        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25261        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25262        pipe.adapter = transport_binding;
25263        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25264        let registry = AgentRegistry::new([spec]).unwrap();
25265        assert!(matches!(
25266            provider_contract_manifest(&registry),
25267            Err(NodeServerError::ProviderContractManifest(message))
25268                if message.contains("conflicting OneShot adapter bindings")
25269        ));
25270    }
25271
25272    #[test]
25273    fn public_node_failures_expose_only_fixed_categories() {
25274        let provider = failure(
25275            NodeFailureCode::BackendOperationFailed,
25276            r"provider bearer-secret failed at C:\private\session.jsonl",
25277        );
25278        assert_eq!(provider.message, "backend-operation-failed");
25279        assert!(!provider.message.contains("bearer-secret"));
25280        assert!(!provider.message.contains(r"C:\private"));
25281        let persistence = persistence_failure(io::Error::new(
25282            io::ErrorKind::Other,
25283            r"state write failed at C:\private\state-v1.json",
25284        ));
25285        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25286        assert!(!persistence.message.contains(r"C:\private"));
25287
25288        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25289            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25290            assert_eq!(
25291                generic.to_string(),
25292                "node durable state failed: durable-state-load-failed",
25293            );
25294        }
25295    }
25296
25297    #[test]
25298    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25299        let sequence = b"\x1b[1;5D".to_vec();
25300        assert_eq!(
25301            terminal_bytes_action(sequence.clone()).unwrap(),
25302            InputAction::TerminalBytes(sequence),
25303        );
25304        assert_eq!(
25305            terminal_bytes_action(Vec::new()).unwrap_err().code,
25306            NodeFailureCode::InvalidRequest,
25307        );
25308        assert_eq!(
25309            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25310                .unwrap_err()
25311                .code,
25312            NodeFailureCode::InvalidRequest,
25313        );
25314    }
25315
25316    #[test]
25317    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25318        let root = temporary_workspace_root("exclusive-state-owner");
25319        std::fs::create_dir_all(&root).unwrap();
25320        let workspace = WorkspaceConfig::new(
25321            WorkspaceId::new("primary").unwrap(),
25322            &root,
25323        )
25324        .unwrap();
25325        let state_path = root.join("state-v1.json");
25326        let endpoint = format!(
25327            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25328            std::process::id(),
25329            std::time::SystemTime::now()
25330                .duration_since(std::time::UNIX_EPOCH)
25331                .unwrap()
25332                .as_nanos(),
25333        );
25334        let config = NodeServerConfig::new(
25335            endpoint,
25336            "fixture-token",
25337            NodeId::new("node-1").unwrap(),
25338            [workspace],
25339        )
25340        .unwrap()
25341        .with_state_path(&state_path)
25342        .unwrap();
25343
25344        let first = NodeServer::new(config.clone()).unwrap();
25345        let error = match NodeServer::new(config.clone()) {
25346            Ok(_) => panic!("a second node server acquired the same durable state path"),
25347            Err(error) => error,
25348        };
25349        assert!(matches!(
25350            &error,
25351            NodeServerError::DurableState(error)
25352                if error.kind() == io::ErrorKind::WouldBlock
25353        ));
25354        assert_eq!(
25355            error.to_string(),
25356            "node durable state failed: durable-state-lock-failed",
25357        );
25358        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25359        drop(first);
25360        let second = NodeServer::new(config).unwrap();
25361        drop(second);
25362        std::fs::remove_dir_all(root).unwrap();
25363    }
25364
25365    #[test]
25366    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25367        let catalog = active_registry().unwrap();
25368        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25369        let workspace_id = WorkspaceId::new("primary").unwrap();
25370        let workspace = WorkspaceConfig::new(
25371            workspace_id.clone(),
25372            std::env::current_dir().unwrap(),
25373        )
25374        .unwrap();
25375        let shared = NodeShared::new(
25376            handle,
25377            "fixture-token".to_owned(),
25378            NodeId::new("node-1").unwrap(),
25379            vec![workspace.clone()],
25380            vec![agent("claude"), agent("codex"), agent("kimi")],
25381        );
25382        let instance_id = AgentInstanceId(17);
25383        let generation = SessionGeneration(3);
25384        assert!(shared.address_for(instance_id, generation).is_none());
25385        shared.bind_session(instance_id, workspace_id.clone(), generation);
25386        let address = SessionAddress {
25387            workspace_id,
25388            session: SessionKey { instance_id, generation },
25389        };
25390        assert_eq!(
25391            shared.workspace_root(&address.workspace_id).unwrap(),
25392            workspace.canonical_root,
25393        );
25394        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25395        shared.remove_binding(&address);
25396        assert!(shared.address_for(instance_id, generation).is_none());
25397    }
25398
25399    #[test]
25400    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25401        let address = SessionAddress {
25402            workspace_id: WorkspaceId::new("primary").unwrap(),
25403            session: SessionKey {
25404                instance_id: AgentInstanceId(7),
25405                generation: SessionGeneration(2),
25406            },
25407        };
25408        let record_id = SessionRecordId::new("record-a").unwrap();
25409        let size = gate4agent_types::TerminalSize {
25410            rows: 24,
25411            columns: 80,
25412        };
25413        let address_requests = [
25414            NodeRequest::Resume {
25415                session: address.clone(),
25416                terminal_size: size,
25417                initial_prompt: None,
25418            },
25419            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25420            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25421            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25422            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25423            NodeRequest::TerminalControl {
25424                session: address.clone(),
25425                control: TerminalControl::Enter,
25426            },
25427            NodeRequest::Resize { session: address.clone(), size },
25428            NodeRequest::Interrupt { session: address.clone() },
25429            NodeRequest::Stop { session: address.clone(), force: false },
25430            NodeRequest::Remove { session: address },
25431        ];
25432        for request in &address_requests {
25433            assert!(request_requires_open_provider_ids_with(
25434                request,
25435                |_| Some(agent("third-party-agent")),
25436                |_| Some(agent("claude")),
25437            ));
25438            assert!(!request_requires_open_provider_ids_with(
25439                request,
25440                |_| Some(agent("claude")),
25441                |_| Some(agent("claude")),
25442            ));
25443            assert!(request_requires_open_provider_ids_with(
25444                request,
25445                |_| None,
25446                |_| Some(agent("claude")),
25447            ));
25448        }
25449
25450        let record_requests = [
25451            NodeRequest::RenameSessionRecord {
25452                record_id: record_id.clone(),
25453                display_name: "renamed".to_owned(),
25454            },
25455            NodeRequest::ResumeSessionRecord {
25456                record_id: record_id.clone(),
25457                terminal_size: size,
25458                initial_prompt: None,
25459            },
25460            NodeRequest::ForgetSessionRecord { record_id },
25461        ];
25462        for request in &record_requests {
25463            assert!(request_requires_open_provider_ids_with(
25464                request,
25465                |_| Some(agent("claude")),
25466                |_| Some(agent("grok")),
25467            ));
25468            assert!(!request_requires_open_provider_ids_with(
25469                request,
25470                |_| Some(agent("claude")),
25471                |_| Some(agent("kimi")),
25472            ));
25473            assert!(request_requires_open_provider_ids_with(
25474                request,
25475                |_| Some(agent("claude")),
25476                |_| None,
25477            ));
25478        }
25479
25480        assert!(request_requires_open_provider_ids_with(
25481            &NodeRequest::Spawn {
25482                workspace_id: WorkspaceId::new("primary").unwrap(),
25483                provider: agent("third-party-agent"),
25484                mode: SessionMode::Pty,
25485                terminal_size: size,
25486                initial_prompt: None,
25487            },
25488            |_| None,
25489            |_| None,
25490        ));
25491        assert!(!request_requires_open_provider_ids_with(
25492            &NodeRequest::Snapshot,
25493            |_| None,
25494            |_| None,
25495        ));
25496        assert!(request_requires_open_provider_ids_with(
25497            &NodeRequest::ForgetContextPack {
25498                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25499            },
25500            |_| None,
25501            |_| None,
25502        ));
25503    }
25504
25505    #[test]
25506    fn legacy_projection_filters_open_record_replies_and_event_history() {
25507        let catalog = active_registry().unwrap();
25508        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25509        let workspace = WorkspaceConfig::new(
25510            WorkspaceId::new("primary").unwrap(),
25511            std::env::current_dir().unwrap(),
25512        )
25513        .unwrap();
25514        let shared = NodeShared::new(
25515            handle,
25516            "fixture-token".to_owned(),
25517            NodeId::new("node-1").unwrap(),
25518            vec![workspace],
25519            vec![agent("claude"), agent("grok")],
25520        );
25521        let legacy = record("claude", "legacy-record");
25522        let open = record("grok", "open-record");
25523        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25524            record: legacy.clone(),
25525        });
25526        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25527            record: open.clone(),
25528        });
25529        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25530            record_id: legacy.record_id.clone(),
25531        });
25532        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25533            record_id: open.record_id.clone(),
25534        });
25535        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25536        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25537        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25538        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25539
25540        let mut record_reply = ResponseEnvelope {
25541            request_id: 1,
25542            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25543        };
25544        project_response_legacy_provider_ids(&shared, &mut record_reply);
25545        assert!(matches!(
25546            record_reply.result,
25547            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25548        ));
25549
25550        let mut resync = ResponseEnvelope {
25551            request_id: 2,
25552            result: Ok(NodeResponse::Resync {
25553                event_sequence: open_removed.sequence,
25554                oldest_available_sequence: 1,
25555                snapshot: shared.snapshot(),
25556                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25557            }),
25558        };
25559        project_response_legacy_provider_ids(&shared, &mut resync);
25560        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25561            panic!("legacy projection changed the resync response kind");
25562        };
25563        assert!(snapshot.session_records.iter().all(|item| {
25564            provider_id_is_legacy(&item.provider)
25565        }));
25566        assert_eq!(events.len(), 2);
25567        assert!(events.iter().all(|event| matches!(
25568            &event.event,
25569            NodeEvent::SessionRecordUpserted { record }
25570                if provider_id_is_legacy(&record.provider)
25571        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25572    }
25573
25574    #[test]
25575    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25576        let catalog = active_registry().unwrap();
25577        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25578        let workspace_id = WorkspaceId::new("primary").unwrap();
25579        let workspace = WorkspaceConfig::new(
25580            workspace_id.clone(),
25581            std::env::current_dir().unwrap(),
25582        )
25583        .unwrap();
25584        let shared = NodeShared::new(
25585            handle,
25586            "fixture-token".to_owned(),
25587            NodeId::new("node-1").unwrap(),
25588            vec![workspace],
25589            vec![agent("claude")],
25590        );
25591        for index in 0..CONTROL_SESSIONS_MAX {
25592            shared.bind_session(
25593                AgentInstanceId((index + 1) as u64),
25594                workspace_id.clone(),
25595                SessionGeneration(1),
25596            );
25597        }
25598        let error = shared.ensure_binding_capacity().unwrap_err();
25599        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25600    }
25601
25602    #[tokio::test]
25603    async fn provider_spawn_admission_rejects_before_session_mutation() {
25604        let catalog = active_registry().unwrap();
25605        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25606        let workspace = WorkspaceConfig::new(
25607            WorkspaceId::new("primary").unwrap(),
25608            std::env::current_dir().unwrap(),
25609        )
25610        .unwrap();
25611        let mut shared = NodeShared::new(
25612            handle,
25613            "fixture-token".to_owned(),
25614            NodeId::new("node-provider-admission").unwrap(),
25615            vec![workspace],
25616            vec![agent("claude")],
25617        );
25618        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25619            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25620        ])
25621        .unwrap();
25622        let terminal_size = gate4agent_types::TerminalSize {
25623            rows: 24,
25624            columns: 80,
25625        };
25626
25627        let unavailable = shared
25628            .spawn_session(
25629                WorkspaceId::new("primary").unwrap(),
25630                agent("claude"),
25631                SessionMode::Pty,
25632                terminal_size,
25633                None,
25634            )
25635            .await
25636            .unwrap_err();
25637        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25638        assert_eq!(unavailable.message, "backend-operation-failed");
25639
25640        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25641            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25642                agent("claude"),
25643                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25644            ),
25645        ])
25646        .unwrap();
25647        let semantic = shared
25648            .spawn_session(
25649                WorkspaceId::new("primary").unwrap(),
25650                agent("claude"),
25651                SessionMode::Inline,
25652                terminal_size,
25653                None,
25654            )
25655            .await
25656            .unwrap_err();
25657        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25658        assert_eq!(semantic.message, "unsupported-capability");
25659
25660        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25661        assert!(shared
25662            .session_bindings
25663            .lock()
25664            .unwrap_or_else(|poisoned| poisoned.into_inner())
25665            .is_empty());
25666        assert!(shared
25667            .session_records
25668            .lock()
25669            .unwrap_or_else(|poisoned| poisoned.into_inner())
25670            .records
25671            .is_empty());
25672        assert!(shared.handle.snapshot().sessions.is_empty());
25673    }
25674
25675    #[tokio::test]
25676    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25677        let catalog = active_registry().unwrap();
25678        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25679        let workspace_id = WorkspaceId::new("primary").unwrap();
25680        let workspace = WorkspaceConfig::new(
25681            workspace_id.clone(),
25682            std::env::current_dir().unwrap(),
25683        )
25684        .unwrap();
25685        let shared = NodeShared::new(
25686            handle,
25687            "fixture-token".to_owned(),
25688            NodeId::new("node-raw-spawn-record").unwrap(),
25689            vec![workspace],
25690            vec![agent("claude")],
25691        );
25692        let address = SessionAddress {
25693            workspace_id,
25694            session: SessionKey {
25695                instance_id: AgentInstanceId(71),
25696                generation: SessionGeneration::default(),
25697            },
25698        };
25699
25700        let record_id = shared
25701            .bind_spawn_session(
25702                &address,
25703                agent("claude"),
25704                SessionMode::Pty,
25705                ProviderRuntimePolicy::raw_pty(),
25706                SpawnRecordPolicy::Always,
25707                None,
25708            )
25709            .unwrap()
25710            .unwrap();
25711        shared
25712            .handle
25713            .dispatch(shared.prepare_command(ControlCommand::Register {
25714                instance_id: address.session.instance_id,
25715                agent_id: agent("claude"),
25716                transport: TransportKind::Pty,
25717            }))
25718            .unwrap();
25719        runtime.tick().await;
25720
25721        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25722            session.instance_id == address.session.instance_id
25723                && session.generation == address.session.generation
25724        }));
25725        let record = shared.record(&record_id).unwrap();
25726        assert_eq!(record.display_name, "claude #1");
25727        assert_eq!(record.state, ManagedSessionState::Live);
25728        assert_eq!(record.active_session.as_ref(), Some(&address));
25729        assert!(record.provider_session.is_none());
25730        let binding_record_id = shared
25731            .session_bindings
25732            .lock()
25733            .unwrap_or_else(|poisoned| poisoned.into_inner())
25734            .get(&address.session.instance_id)
25735            .and_then(|binding| binding.record_id.clone());
25736        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25737
25738        let renamed = shared
25739            .rename_session_record(&record_id, "raw review".to_owned())
25740            .unwrap();
25741        assert_eq!(renamed.record_id, record_id);
25742        assert_eq!(renamed.display_name, "raw review");
25743        shared.reconcile_managed_record(
25744            &record_id,
25745            &address,
25746            &ControlEvent {
25747                sequence: 1,
25748                command_id: None,
25749                instance_id: address.session.instance_id,
25750                generation: address.session.generation,
25751                event: ControlEventKind::Running { process_id: None },
25752            },
25753            false,
25754            false,
25755        );
25756        let running = shared.record(&record_id).unwrap();
25757        assert_eq!(running.state, ManagedSessionState::Live);
25758        assert_eq!(running.active_session.as_ref(), Some(&address));
25759        assert_eq!(running.display_name, "raw review");
25760
25761        shared.reconcile_managed_record(
25762            &record_id,
25763            &address,
25764            &ControlEvent {
25765                sequence: 2,
25766                command_id: None,
25767                instance_id: address.session.instance_id,
25768                generation: address.session.generation,
25769                event: ControlEventKind::Exited {
25770                    exit_code: Some(0),
25771                    forced: false,
25772                },
25773            },
25774            false,
25775            false,
25776        );
25777        let detached = shared.record(&record_id).unwrap();
25778        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25779        assert!(detached.active_session.is_none());
25780        assert_eq!(detached.display_name, "raw review");
25781        let renamed_detached = shared
25782            .rename_session_record(&record_id, "raw review detached".to_owned())
25783            .unwrap();
25784        assert_eq!(renamed_detached.record_id, record_id);
25785        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25786        assert!(renamed_detached.active_session.is_none());
25787        assert_eq!(renamed_detached.display_name, "raw review detached");
25788        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25789    }
25790
25791    #[test]
25792    fn legacy_raw_spawn_binding_remains_untracked() {
25793        let catalog = active_registry().unwrap();
25794        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25795        let workspace_id = WorkspaceId::new("primary").unwrap();
25796        let workspace = WorkspaceConfig::new(
25797            workspace_id.clone(),
25798            std::env::current_dir().unwrap(),
25799        )
25800        .unwrap();
25801        let shared = NodeShared::new(
25802            handle,
25803            "fixture-token".to_owned(),
25804            NodeId::new("node-legacy-raw-spawn").unwrap(),
25805            vec![workspace],
25806            vec![agent("claude")],
25807        );
25808        let address = SessionAddress {
25809            workspace_id,
25810            session: SessionKey {
25811                instance_id: AgentInstanceId(72),
25812                generation: SessionGeneration::default(),
25813            },
25814        };
25815
25816        let record_id = shared
25817            .bind_spawn_session(
25818                &address,
25819                agent("claude"),
25820                SessionMode::Pty,
25821                ProviderRuntimePolicy::raw_pty(),
25822                SpawnRecordPolicy::ProviderIdentityOnly,
25823                None,
25824            )
25825            .unwrap();
25826
25827        assert!(record_id.is_none());
25828        let binding_record_id = shared
25829            .session_bindings
25830            .lock()
25831            .unwrap_or_else(|poisoned| poisoned.into_inner())
25832            .get(&address.session.instance_id)
25833            .and_then(|binding| binding.record_id.clone());
25834        assert!(binding_record_id.is_none());
25835        assert!(shared.snapshot().session_records.is_empty());
25836    }
25837
25838    #[tokio::test]
25839    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25840        let catalog = active_registry().unwrap();
25841        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25842        let workspace_id = WorkspaceId::new("primary").unwrap();
25843        let workspace = WorkspaceConfig::new(
25844            workspace_id.clone(),
25845            std::env::current_dir().unwrap(),
25846        )
25847        .unwrap();
25848        let mut shared = NodeShared::new(
25849            handle,
25850            "fixture-token".to_owned(),
25851            NodeId::new("node-raw-semantic-gate").unwrap(),
25852            vec![workspace],
25853            vec![agent("claude")],
25854        );
25855        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25856            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25857        ])
25858        .unwrap();
25859        shared
25860            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25861            .unwrap();
25862        let address = SessionAddress {
25863            workspace_id,
25864            session: SessionKey {
25865                instance_id: AgentInstanceId(72),
25866                generation: SessionGeneration::default(),
25867            },
25868        };
25869        shared.bind_session_with_policy(
25870            &address,
25871            ProviderRuntimePolicy::raw_pty(),
25872            None,
25873        );
25874        shared
25875            .handle
25876            .dispatch(shared.prepare_command(ControlCommand::Register {
25877                instance_id: address.session.instance_id,
25878                agent_id: agent("claude"),
25879                transport: TransportKind::Pty,
25880            }))
25881            .unwrap();
25882        runtime.tick().await;
25883        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25884        let revision_before = shared.handle.snapshot().revision;
25885        let history_sequence_before = shared.current_sequence();
25886        let terminal_size = gate4agent_types::TerminalSize {
25887            rows: 24,
25888            columns: 80,
25889        };
25890
25891        for request in [
25892            NodeRequest::Prompt {
25893                session: address.clone(),
25894                text: "prompt".to_owned(),
25895            },
25896            NodeRequest::Paste {
25897                session: address.clone(),
25898                text: "paste".to_owned(),
25899            },
25900            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25901            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25902            // (promptless) resume is a raw PTY relaunch and IS admitted under
25903            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25904            // in `provider_runtime.rs`; it is deliberately not exercised here.
25905            NodeRequest::Resume {
25906                session: address.clone(),
25907                terminal_size,
25908                initial_prompt: Some("resume".to_owned()),
25909            },
25910        ] {
25911            let error = process_request_inner(
25912                &shared,
25913                77,
25914                ClientRole::Operator,
25915                request,
25916            )
25917            .await
25918            .unwrap_err();
25919            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25920        }
25921
25922        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25923        assert_eq!(shared.handle.snapshot().revision, revision_before);
25924        assert_eq!(shared.current_sequence(), history_sequence_before);
25925        let bindings = shared
25926            .session_bindings
25927            .lock()
25928            .unwrap_or_else(|poisoned| poisoned.into_inner());
25929        let binding = bindings.get(&address.session.instance_id).unwrap();
25930        assert_eq!(binding.generation, address.session.generation);
25931        assert!(binding.pending_resume.is_none());
25932        assert!(binding.record_id.is_none());
25933    }
25934
25935    #[tokio::test]
25936    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25937        let catalog = active_registry().unwrap();
25938        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25939        let primary = WorkspaceConfig::new(
25940            WorkspaceId::new("primary").unwrap(),
25941            std::env::current_dir().unwrap(),
25942        )
25943        .unwrap();
25944        let shared = NodeShared::new(
25945            handle,
25946            "fixture-token".to_owned(),
25947            NodeId::new("node-1").unwrap(),
25948            vec![primary],
25949            vec![agent("claude")],
25950        );
25951        shared
25952            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25953            .unwrap();
25954        let before = shared.snapshot();
25955        let error = process_request_inner(
25956            &shared,
25957            77,
25958            ClientRole::Operator,
25959            NodeRequest::RegisterWorkspace {
25960                workspace_id: WorkspaceId::new("foreign").unwrap(),
25961                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25962            },
25963        )
25964        .await
25965        .unwrap_err();
25966        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25967        assert_eq!(error.message, "invalid-request");
25968        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25969    }
25970
25971    #[tokio::test]
25972    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25973        let catalog = active_registry().unwrap();
25974        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25975        let primary_id = WorkspaceId::new("primary").unwrap();
25976        let primary = WorkspaceConfig::new(
25977            primary_id.clone(),
25978            std::env::current_dir().unwrap(),
25979        )
25980        .unwrap();
25981        let shared = NodeShared::new(
25982            handle,
25983            "fixture-token".to_owned(),
25984            NodeId::new("node-1").unwrap(),
25985            vec![primary.clone()],
25986            vec![agent("claude")],
25987        );
25988        let secondary_id = WorkspaceId::new("secondary").unwrap();
25989        let secondary = shared
25990            .register_workspace(
25991                secondary_id.clone(),
25992                std::env::temp_dir().to_string_lossy().into_owned(),
25993            )
25994            .await
25995            .unwrap();
25996        assert_eq!(secondary.workspace_id, secondary_id);
25997        assert!(secondary.sessions.is_empty());
25998        assert_eq!(shared.snapshot().workspaces.len(), 2);
25999
26000        let duplicate_id = shared
26001            .register_workspace(
26002                secondary_id.clone(),
26003                primary.canonical_root().to_owned(),
26004            )
26005            .await
26006            .unwrap_err();
26007        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26008        let duplicate_root = shared
26009            .register_workspace(
26010                WorkspaceId::new("duplicate-root").unwrap(),
26011                windows_path_text(&secondary.canonical_root).to_owned(),
26012            )
26013            .await
26014            .unwrap_err();
26015        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26016
26017        let address = SessionAddress {
26018            workspace_id: secondary_id.clone(),
26019            session: SessionKey {
26020                instance_id: AgentInstanceId(41),
26021                generation: SessionGeneration(1),
26022            },
26023        };
26024        shared.bind_session(
26025            address.session.instance_id,
26026            secondary_id.clone(),
26027            address.session.generation,
26028        );
26029        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26030        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26031        shared.remove_binding(&address);
26032        shared.unregister_workspace(&secondary_id).unwrap();
26033        assert_eq!(shared.snapshot().workspaces.len(), 1);
26034
26035        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26036        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26037        let history = shared
26038            .history
26039            .lock()
26040            .unwrap_or_else(|poisoned| poisoned.into_inner());
26041        assert!(matches!(
26042            &history.events[0],
26043            NodeEventEnvelope {
26044                sequence: 1,
26045                event: NodeEvent::WorkspaceAdded { workspace },
26046            } if workspace.workspace_id == secondary_id
26047        ));
26048        assert!(matches!(
26049            &history.events[1],
26050            NodeEventEnvelope {
26051                sequence: 2,
26052                event: NodeEvent::WorkspaceRemoved { workspace_id },
26053            } if workspace_id == &secondary_id
26054        ));
26055    }
26056
26057    #[tokio::test]
26058    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26059        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26060        std::fs::create_dir_all(&secondary_root).unwrap();
26061        let catalog = active_registry().unwrap();
26062        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26063        let primary_id = WorkspaceId::new("primary").unwrap();
26064        let secondary_id = WorkspaceId::new("secondary").unwrap();
26065        let shared = NodeShared::new(
26066            handle,
26067            "fixture-token".to_owned(),
26068            NodeId::new("node-session-reference-index").unwrap(),
26069            vec![
26070                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26071                    .unwrap(),
26072                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26073            ],
26074            vec![agent("claude"), agent("codex")],
26075        );
26076        let identity = ProviderSessionIdentity {
26077            key: gate4agent_types::ProviderSessionKey::SessionId,
26078            id: "provider-session-42".to_owned(),
26079            transcript_path: None,
26080        };
26081        let index_request = |workspace_id: WorkspaceId,
26082                             provider: AgentId,
26083                             identity: ProviderSessionIdentity,
26084                             display_name: &str| {
26085            NodeRequest::IndexProviderSession {
26086                workspace_id,
26087                provider,
26088                identity,
26089                display_name: display_name.to_owned(),
26090            }
26091        };
26092        let request = index_request(
26093            primary_id.clone(),
26094            agent("claude"),
26095            identity.clone(),
26096            "release shepherd",
26097        );
26098        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26099            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26100        }));
26101        let observer = process_request_inner(
26102            &shared,
26103            77,
26104            ClientRole::Observer,
26105            request.clone(),
26106        )
26107        .await
26108        .unwrap_err();
26109        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26110        let uncontrolled = process_request_inner(
26111            &shared,
26112            77,
26113            ClientRole::Operator,
26114            request.clone(),
26115        )
26116        .await
26117        .unwrap_err();
26118        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26119        shared
26120            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26121            .unwrap();
26122
26123        let indexed = process_request_inner(
26124            &shared,
26125            77,
26126            ClientRole::Operator,
26127            request.clone(),
26128        )
26129        .await
26130        .unwrap();
26131        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26132            panic!("provider session reference index returned another response");
26133        };
26134        assert_eq!(record.state, ManagedSessionState::Dormant);
26135        assert_eq!(record.mode, SessionMode::Pty);
26136        assert_eq!(record.workspace_id, primary_id);
26137        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26138        assert!(record.active_session.is_none());
26139        assert!(record.environment_profile.is_none());
26140        assert!(record.bundle.is_none());
26141        assert!(record.context.is_none());
26142
26143        let repeated = process_request_inner(
26144            &shared,
26145            77,
26146            ClientRole::Operator,
26147            index_request(
26148                primary_id.clone(),
26149                agent("claude"),
26150                identity.clone(),
26151                "ignored replacement",
26152            ),
26153        )
26154        .await
26155        .unwrap();
26156        assert!(matches!(
26157            repeated,
26158            NodeResponse::ProviderSessionIndexed { record: ref existing }
26159                if existing.record_id == record.record_id
26160                    && existing.display_name == "release shepherd"
26161        ));
26162        let conflicting_workspace = process_request_inner(
26163            &shared,
26164            77,
26165            ClientRole::Operator,
26166            index_request(
26167                secondary_id,
26168                agent("claude"),
26169                identity.clone(),
26170                "release shepherd",
26171            ),
26172        )
26173        .await
26174        .unwrap_err();
26175        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26176        let conflicting_provider = process_request_inner(
26177            &shared,
26178            77,
26179            ClientRole::Operator,
26180            index_request(
26181                primary_id.clone(),
26182                agent("codex"),
26183                identity.clone(),
26184                "release shepherd",
26185            ),
26186        )
26187        .await
26188        .unwrap_err();
26189        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26190        let transcript_rejected = process_request_inner(
26191            &shared,
26192            77,
26193            ClientRole::Operator,
26194            index_request(
26195                primary_id,
26196                agent("claude"),
26197                ProviderSessionIdentity {
26198                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26199                    ..identity
26200                },
26201                "release shepherd",
26202            ),
26203        )
26204        .await
26205        .unwrap_err();
26206        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26207        let upserts = shared
26208            .history
26209            .lock()
26210            .unwrap_or_else(|poisoned| poisoned.into_inner())
26211            .events
26212            .iter()
26213            .filter(|event| matches!(
26214                &event.event,
26215                NodeEvent::SessionRecordUpserted { record: published }
26216                    if published.record_id == record.record_id
26217            ))
26218            .count();
26219        assert_eq!(upserts, 1);
26220        std::fs::remove_dir_all(secondary_root).unwrap();
26221    }
26222
26223    #[tokio::test]
26224    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26225        let fixture_root = temporary_workspace_root("standalone-independent");
26226        let target = fixture_root.join("independent");
26227        std::fs::create_dir_all(&fixture_root).unwrap();
26228        let catalog = active_registry().unwrap();
26229        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26230        let selected = WorkspaceConfig::new(
26231            WorkspaceId::new("primary").unwrap(),
26232            std::env::current_dir().unwrap(),
26233        )
26234        .unwrap();
26235        let selected_root = selected.canonical_root().to_owned();
26236        let shared = NodeShared::new(
26237            handle,
26238            "fixture-token".to_owned(),
26239            NodeId::new("node-standalone").unwrap(),
26240            vec![selected],
26241            vec![agent("claude")],
26242        );
26243        let workspace_id = WorkspaceId::new("independent").unwrap();
26244        let request = NodeRequest::CreateStandaloneWorkspace {
26245            workspace_id: workspace_id.clone(),
26246            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26247            initial_branch: Some("main".to_owned()),
26248        };
26249        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26250            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26251        }));
26252        assert!(!request_uses_unnegotiated_capability(
26253            &request,
26254            &baseline_capabilities().unwrap(),
26255        ));
26256        assert!(request_uses_unnegotiated_capability(&request, &[]));
26257
26258        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26259            .await
26260            .unwrap_err();
26261        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26262        assert!(!target.exists());
26263        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26264            .await
26265            .unwrap_err();
26266        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26267        assert!(!target.exists());
26268        shared
26269            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26270            .unwrap();
26271
26272        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26273            .await
26274            .unwrap();
26275        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26276            panic!("standalone workspace creation returned another response");
26277        };
26278        assert_eq!(workspace.workspace_id, workspace_id);
26279        assert!(target.join(".git").is_dir());
26280        let branch = run_git_bounded(
26281            target.to_str().unwrap(),
26282            &["branch", "--show-current"],
26283            GIT_OUTPUT_MAX_BYTES,
26284        )
26285        .await
26286        .unwrap();
26287        assert!(branch.success);
26288        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26289        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26290        assert_eq!(worktrees.len(), 1);
26291        let listed_root = WorkspaceConfig::new(
26292            WorkspaceId::new("listed-independent").unwrap(),
26293            &worktrees[0].path,
26294        )
26295        .unwrap();
26296        assert!(platform::roots_equal(
26297            listed_root.canonical_root(),
26298            windows_path_text(&workspace.canonical_root),
26299        ));
26300        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26301        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26302            snapshot.workspace_id == workspace_id
26303                && snapshot.canonical_root == workspace.canonical_root
26304        }));
26305        assert!(shared
26306            .history
26307            .lock()
26308            .unwrap_or_else(|poisoned| poisoned.into_inner())
26309            .events
26310            .iter()
26311            .any(|event| matches!(
26312                &event.event,
26313                NodeEvent::WorkspaceAdded { workspace }
26314                    if workspace.workspace_id == workspace_id
26315            )));
26316        std::fs::remove_dir_all(fixture_root).unwrap();
26317    }
26318
26319    #[tokio::test]
26320    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26321        let fixture_root = temporary_workspace_root("standalone-compensation");
26322        let created_target = fixture_root.join("created-target");
26323        let existing_target = fixture_root.join("existing-target");
26324        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26325        let invalid_state_path = invalid_state_parent.join("state.json");
26326        std::fs::create_dir_all(&existing_target).unwrap();
26327        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26328        let catalog = active_registry().unwrap();
26329        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26330        let primary = WorkspaceConfig::new(
26331            WorkspaceId::new("primary").unwrap(),
26332            std::env::current_dir().unwrap(),
26333        )
26334        .unwrap();
26335        let mut shared = NodeShared::new(
26336            handle,
26337            "fixture-token".to_owned(),
26338            NodeId::new("node-standalone-compensation").unwrap(),
26339            vec![primary],
26340            vec![agent("claude")],
26341        );
26342        shared.state_path = Some(invalid_state_path);
26343
26344        let created_recovery = shared
26345            .create_standalone_workspace(
26346                WorkspaceId::new("compensated").unwrap(),
26347                created_target.to_string_lossy().into_owned(),
26348                Some("main".to_owned()),
26349            )
26350            .await
26351            .unwrap_err();
26352        assert_eq!(
26353            created_recovery.code,
26354            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26355        );
26356        assert!(created_target.join(".git").is_dir());
26357        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26358            workspace.workspace_id.as_str() != "compensated"
26359        }));
26360
26361        let existing_recovery = shared
26362            .create_standalone_workspace(
26363                WorkspaceId::new("restored").unwrap(),
26364                existing_target.to_string_lossy().into_owned(),
26365                None,
26366            )
26367            .await
26368            .unwrap_err();
26369        assert_eq!(
26370            existing_recovery.code,
26371            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26372        );
26373        assert!(existing_target.is_dir());
26374        assert!(existing_target.join(".git").is_dir());
26375        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26376            workspace.workspace_id.as_str() != "restored"
26377        }));
26378
26379        let registered_empty = fixture_root.join("registered-empty");
26380        std::fs::create_dir(&registered_empty).unwrap();
26381        let catalog = active_registry().unwrap();
26382        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26383        let registered = WorkspaceConfig::new(
26384            WorkspaceId::new("registered-empty").unwrap(),
26385            &registered_empty,
26386        )
26387        .unwrap();
26388        let duplicate_shared = NodeShared::new(
26389            handle,
26390            "fixture-token".to_owned(),
26391            NodeId::new("node-standalone-duplicate-root").unwrap(),
26392            vec![registered],
26393            vec![agent("claude")],
26394        );
26395        let duplicate = duplicate_shared
26396            .create_standalone_workspace(
26397                WorkspaceId::new("duplicate-root").unwrap(),
26398                registered_empty.to_string_lossy().into_owned(),
26399                None,
26400            )
26401            .await
26402            .unwrap_err();
26403        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26404        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26405        std::fs::remove_dir_all(fixture_root).unwrap();
26406    }
26407
26408    #[test]
26409    fn managed_record_id_collision_preserves_the_existing_record() {
26410        let catalog = active_registry().unwrap();
26411        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26412        let workspace = WorkspaceConfig::new(
26413            WorkspaceId::new("primary").unwrap(),
26414            std::env::current_dir().unwrap(),
26415        )
26416        .unwrap();
26417        let shared = NodeShared::new(
26418            handle,
26419            "fixture-token".to_owned(),
26420            NodeId::new("node-1").unwrap(),
26421            vec![workspace.clone()],
26422            vec![agent("claude")],
26423        );
26424        let record_id = SessionRecordId::new("sr-collision").unwrap();
26425        let original = ManagedSessionRecord {
26426            record_id: record_id.clone(),
26427            display_name: "original".to_owned(),
26428            provider: agent("claude"),
26429            mode: SessionMode::Pty,
26430            state: ManagedSessionState::Dormant,
26431            workspace_id: workspace.workspace_id,
26432            canonical_root: opaque_windows_path(workspace.canonical_root),
26433            provider_session: None,
26434            active_session: None,
26435            environment_profile: None,
26436            bundle: None,
26437            context_id: None,
26438            context: None,
26439            exported_context: None,
26440            task_binding: None,
26441            created_at_unix_ms: 1,
26442            updated_at_unix_ms: 1,
26443            last_error: None,
26444        };
26445        shared.insert_record(original.clone()).unwrap();
26446        let mut collision = original.clone();
26447        collision.display_name = "replacement".to_owned();
26448        let error = shared.insert_record(collision).unwrap_err();
26449        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26450        assert_eq!(shared.record(&record_id).unwrap(), original);
26451    }
26452
26453    #[test]
26454    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26455        let catalog = active_registry().unwrap();
26456        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26457        let workspace = WorkspaceConfig::new(
26458            WorkspaceId::new("primary").unwrap(),
26459            std::env::current_dir().unwrap(),
26460        )
26461        .unwrap();
26462        let shared = NodeShared::new(
26463            handle,
26464            "fixture-token".to_owned(),
26465            NodeId::new("node-1").unwrap(),
26466            vec![workspace.clone()],
26467            vec![agent("claude")],
26468        );
26469        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26470        shared
26471            .insert_record(ManagedSessionRecord {
26472                record_id: record_id.clone(),
26473                display_name: "runtime error".to_owned(),
26474                provider: agent("claude"),
26475                mode: SessionMode::Pty,
26476                state: ManagedSessionState::IdentityPending,
26477                workspace_id: workspace.workspace_id,
26478                canonical_root: opaque_windows_path(workspace.canonical_root),
26479                provider_session: None,
26480                active_session: None,
26481                environment_profile: None,
26482                bundle: None,
26483                context_id: None,
26484                context: None,
26485                exported_context: None,
26486                task_binding: None,
26487                created_at_unix_ms: 1,
26488                updated_at_unix_ms: 1,
26489                last_error: None,
26490            })
26491            .unwrap();
26492
26493        shared
26494            .mark_record_error(
26495                &record_id,
26496                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26497            )
26498            .unwrap();
26499        let record = shared.record(&record_id).unwrap();
26500        assert_eq!(record.state, ManagedSessionState::Unavailable);
26501        assert_eq!(
26502            record.last_error.as_deref(),
26503            Some("provider-runtime-failed"),
26504        );
26505        let snapshot = shared.snapshot();
26506        assert_eq!(snapshot.session_records, vec![record]);
26507    }
26508
26509    /// Regression coverage for the "operator sees `deadline` instead of the
26510    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26511    /// able to learn a command's asynchronous kernel rejection (here,
26512    /// specifically classified as `UnsupportedTransport`) exactly once, and
26513    /// must not still find it after that -- proving the fast path this
26514    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26515    /// loop) drains the reason instead of leaving it to be rediscovered (or
26516    /// missed) on a later poll.
26517    #[test]
26518    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26519        let catalog = active_registry().unwrap();
26520        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26521        let workspace = WorkspaceConfig::new(
26522            WorkspaceId::new("primary").unwrap(),
26523            std::env::current_dir().unwrap(),
26524        )
26525        .unwrap();
26526        let shared = NodeShared::new(
26527            handle,
26528            "fixture-token".to_owned(),
26529            NodeId::new("node-1").unwrap(),
26530            vec![workspace],
26531            vec![agent("claude")],
26532        );
26533
26534        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26535
26536        shared.record_rejected_command(
26537            CommandId(1),
26538            "agent 'claude' does not support transport Acp".to_owned(),
26539            true,
26540        );
26541        // An unrelated command_id must not see another command's reason.
26542        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26543
26544        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26545        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26546        assert!(reason.unsupported_transport);
26547
26548        // Consumed: a second read for the same command_id finds nothing.
26549        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26550
26551        // A rejection unrelated to transport support keeps its own
26552        // classification distinct, so the reader can choose the generic
26553        // `BackendOperationFailed` code instead of misreporting
26554        // `UnsupportedTransport`.
26555        shared.record_rejected_command(
26556            CommandId(3),
26557            "backend busy".to_owned(),
26558            false,
26559        );
26560        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26561        assert!(!unclassified.unsupported_transport);
26562    }
26563
26564    #[test]
26565    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26566        let catalog = active_registry().unwrap();
26567        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26568        let workspace = WorkspaceConfig::new(
26569            WorkspaceId::new("primary").unwrap(),
26570            std::env::current_dir().unwrap(),
26571        )
26572        .unwrap();
26573        let shared = NodeShared::new(
26574            handle,
26575            "fixture-token".to_owned(),
26576            NodeId::new("node-identity-policy").unwrap(),
26577            vec![workspace.clone()],
26578            vec![agent("claude")],
26579        );
26580        let address = SessionAddress {
26581            workspace_id: workspace.workspace_id.clone(),
26582            session: SessionKey {
26583                instance_id: AgentInstanceId(90),
26584                generation: SessionGeneration(1),
26585            },
26586        };
26587        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26588        shared
26589            .insert_record(ManagedSessionRecord {
26590                record_id: record_id.clone(),
26591                display_name: "policy denied".to_owned(),
26592                provider: agent("claude"),
26593                mode: SessionMode::Pty,
26594                state: ManagedSessionState::IdentityPending,
26595                workspace_id: workspace.workspace_id,
26596                canonical_root: opaque_windows_path(workspace.canonical_root),
26597                provider_session: None,
26598                active_session: Some(address.clone()),
26599                environment_profile: None,
26600                bundle: None,
26601                context_id: None,
26602                context: None,
26603                exported_context: None,
26604                task_binding: None,
26605                created_at_unix_ms: 1,
26606                updated_at_unix_ms: 1,
26607                last_error: None,
26608            })
26609            .unwrap();
26610        let event = ControlEvent {
26611            sequence: 1,
26612            command_id: None,
26613            instance_id: address.session.instance_id,
26614            generation: address.session.generation,
26615            event: ControlEventKind::ProviderEvent {
26616                sequence: 1,
26617                source: gate4agent_types::ProviderSource {
26618                    family: AdapterFamily::PtySemantic,
26619                    binding: AdapterBinding::new(
26620                        gate4agent_types::AdapterId::new("claude").unwrap(),
26621                        "fixture/v1",
26622                        gate4agent_types::AdapterVerification::SyntheticFixture,
26623                    )
26624                    .unwrap(),
26625                },
26626                source_sequence: 1,
26627                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26628                    identity: gate4agent_types::ProviderSessionIdentity {
26629                        key: gate4agent_types::ProviderSessionKey::SessionId,
26630                        id: "provider-session".to_owned(),
26631                        transcript_path: None,
26632                    },
26633                },
26634            },
26635        };
26636
26637        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26638
26639        let record = shared.record(&record_id).unwrap();
26640        assert!(record.provider_session.is_none());
26641        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26642        assert_eq!(record.active_session.as_ref(), Some(&address));
26643    }
26644
26645    #[test]
26646    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26647        let secondary_root = temporary_workspace_root("identity-scope");
26648        std::fs::create_dir_all(&secondary_root).unwrap();
26649        let catalog = active_registry().unwrap();
26650        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26651        let primary = WorkspaceConfig::new(
26652            WorkspaceId::new("primary").unwrap(),
26653            std::env::current_dir().unwrap(),
26654        )
26655        .unwrap();
26656        let secondary = WorkspaceConfig::new(
26657            WorkspaceId::new("secondary").unwrap(),
26658            &secondary_root,
26659        )
26660        .unwrap();
26661        let shared = NodeShared::new(
26662            handle,
26663            "fixture-token".to_owned(),
26664            NodeId::new("node-1").unwrap(),
26665            vec![primary.clone(), secondary.clone()],
26666            vec![agent("claude")],
26667        );
26668        let identity = gate4agent_types::ProviderSessionIdentity {
26669            key: gate4agent_types::ProviderSessionKey::SessionId,
26670            id: "provider-session".to_owned(),
26671            transcript_path: None,
26672        };
26673        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26674        shared
26675            .insert_record(ManagedSessionRecord {
26676                record_id: dormant_id.clone(),
26677                display_name: "dormant".to_owned(),
26678                provider: agent("claude"),
26679                mode: SessionMode::Pty,
26680                state: ManagedSessionState::Dormant,
26681                workspace_id: primary.workspace_id,
26682                canonical_root: opaque_windows_path(primary.canonical_root),
26683                provider_session: Some(identity.clone()),
26684                active_session: None,
26685                environment_profile: None,
26686                bundle: None,
26687                context_id: None,
26688                context: None,
26689                exported_context: None,
26690                task_binding: None,
26691                created_at_unix_ms: 1,
26692                updated_at_unix_ms: 1,
26693                last_error: None,
26694            })
26695            .unwrap();
26696        let address = SessionAddress {
26697            workspace_id: secondary.workspace_id.clone(),
26698            session: SessionKey {
26699                instance_id: AgentInstanceId(91),
26700                generation: SessionGeneration(1),
26701            },
26702        };
26703        let current_id = SessionRecordId::new("sr-current").unwrap();
26704        shared
26705            .insert_record(ManagedSessionRecord {
26706                record_id: current_id.clone(),
26707                display_name: "current".to_owned(),
26708                provider: agent("claude"),
26709                mode: SessionMode::Pty,
26710                state: ManagedSessionState::IdentityPending,
26711                workspace_id: secondary.workspace_id,
26712                canonical_root: opaque_windows_path(secondary.canonical_root),
26713                provider_session: None,
26714                active_session: Some(address.clone()),
26715                environment_profile: None,
26716                bundle: None,
26717                context_id: None,
26718                context: None,
26719                exported_context: None,
26720                task_binding: None,
26721                created_at_unix_ms: 2,
26722                updated_at_unix_ms: 2,
26723                last_error: None,
26724            })
26725            .unwrap();
26726        shared.bind_managed_session(
26727            &address,
26728            current_id.clone(),
26729            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26730            None,
26731        );
26732        let event = ControlEvent {
26733            sequence: 1,
26734            command_id: None,
26735            instance_id: address.session.instance_id,
26736            generation: address.session.generation,
26737            event: ControlEventKind::ProviderEvent {
26738                sequence: 1,
26739                source: gate4agent_types::ProviderSource {
26740                    family: gate4agent_types::AdapterFamily::PtySemantic,
26741                    binding: gate4agent_types::AdapterBinding::new(
26742                        gate4agent_types::AdapterId::new("claude").unwrap(),
26743                        "fixture/v1",
26744                        gate4agent_types::AdapterVerification::SyntheticFixture,
26745                    )
26746                    .unwrap(),
26747                },
26748                source_sequence: 1,
26749                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26750                    identity,
26751                },
26752            },
26753        };
26754        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26755
26756        let binding = shared
26757            .session_bindings
26758            .lock()
26759            .unwrap_or_else(|poisoned| poisoned.into_inner())
26760            .get(&address.session.instance_id)
26761            .cloned()
26762            .unwrap();
26763        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26764        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26765        let current = shared.record(&current_id).unwrap();
26766        assert_eq!(current.state, ManagedSessionState::Unavailable);
26767        assert_eq!(current.active_session.as_ref(), Some(&address));
26768        assert_eq!(
26769            current.last_error.as_deref(),
26770            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26771        );
26772        std::fs::remove_dir_all(secondary_root).unwrap();
26773    }
26774
26775    #[tokio::test]
26776    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26777        let root = temporary_workspace_root("semantic-provider-session");
26778        std::fs::create_dir_all(&root).unwrap();
26779        let state_path = root.join("state-v1.json");
26780        let catalog = active_registry().unwrap();
26781        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26782        let workspace = WorkspaceConfig::new(
26783            WorkspaceId::new("primary").unwrap(),
26784            &root,
26785        )
26786        .unwrap();
26787        let node_id = NodeId::new("node-semantic-identity").unwrap();
26788        let shared = NodeShared::new_with_incarnation(
26789            handle,
26790            "fixture-token".to_owned(),
26791            node_id.clone(),
26792            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26793            vec![workspace.clone()],
26794            vec![agent("claude")],
26795            ProviderRuntimeStatuses::default(),
26796            None,
26797            Vec::new(),
26798            Vec::new(),
26799            SpawnProfileRegistry::default(),
26800            None,
26801            None,
26802            None,
26803            Some(state_path.clone()),
26804            Vec::new(),
26805            Vec::new(),
26806            Vec::new(),
26807            Vec::new(),
26808            None,
26809            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26810        );
26811        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26812            key: gate4agent_types::ProviderSessionKey::SessionId,
26813            id: "provider-session".to_owned(),
26814            transcript_path: None,
26815        };
26816        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26817        shared
26818            .insert_record(ManagedSessionRecord {
26819                record_id: durable_id.clone(),
26820                display_name: "durable".to_owned(),
26821                provider: agent("claude"),
26822                mode: SessionMode::Pty,
26823                state: ManagedSessionState::Dormant,
26824                workspace_id: workspace.workspace_id.clone(),
26825                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26826                provider_session: Some(persisted_identity.clone()),
26827                active_session: None,
26828                environment_profile: None,
26829                bundle: None,
26830                context_id: None,
26831                context: None,
26832                exported_context: None,
26833                task_binding: None,
26834                created_at_unix_ms: 1,
26835                updated_at_unix_ms: 1,
26836                last_error: None,
26837            })
26838            .unwrap();
26839        let address = SessionAddress {
26840            workspace_id: workspace.workspace_id.clone(),
26841            session: SessionKey {
26842                instance_id: AgentInstanceId(92),
26843                generation: SessionGeneration(2),
26844            },
26845        };
26846        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26847        shared
26848            .insert_record(ManagedSessionRecord {
26849                record_id: pending_id.clone(),
26850                display_name: "pending".to_owned(),
26851                provider: agent("claude"),
26852                mode: SessionMode::Pty,
26853                state: ManagedSessionState::IdentityPending,
26854                workspace_id: workspace.workspace_id.clone(),
26855                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26856                provider_session: None,
26857                active_session: Some(address.clone()),
26858                environment_profile: None,
26859                bundle: None,
26860                context_id: None,
26861                context: None,
26862                exported_context: None,
26863                task_binding: None,
26864                created_at_unix_ms: 2,
26865                updated_at_unix_ms: 2,
26866                last_error: None,
26867            })
26868            .unwrap();
26869        shared.bind_managed_session(
26870            &address,
26871            pending_id.clone(),
26872            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26873            None,
26874        );
26875        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26876            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26877            ..persisted_identity
26878        };
26879        let event = ControlEvent {
26880            sequence: 1,
26881            command_id: None,
26882            instance_id: address.session.instance_id,
26883            generation: address.session.generation,
26884            event: ControlEventKind::ProviderEvent {
26885                sequence: 1,
26886                source: gate4agent_types::ProviderSource {
26887                    family: gate4agent_types::AdapterFamily::PtySemantic,
26888                    binding: gate4agent_types::AdapterBinding::new(
26889                        gate4agent_types::AdapterId::new("claude").unwrap(),
26890                        "fixture/v1",
26891                        gate4agent_types::AdapterVerification::SyntheticFixture,
26892                    )
26893                    .unwrap(),
26894                },
26895                source_sequence: 1,
26896                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26897                    identity: observed_identity.clone(),
26898                },
26899            },
26900        };
26901        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26902
26903        let snapshot = shared.snapshot();
26904        assert_eq!(snapshot.session_records.len(), 1);
26905        let live = &snapshot.session_records[0];
26906        assert_eq!(live.record_id, durable_id);
26907        assert_eq!(live.state, ManagedSessionState::Live);
26908        assert_eq!(live.active_session.as_ref(), Some(&address));
26909        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26910        let binding = shared
26911            .session_bindings
26912            .lock()
26913            .unwrap_or_else(|poisoned| poisoned.into_inner())
26914            .get(&address.session.instance_id)
26915            .cloned()
26916            .unwrap();
26917        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26918
26919        let mut duplicate = live.clone();
26920        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26921        duplicate.display_name = "duplicate".to_owned();
26922        duplicate.state = ManagedSessionState::Dormant;
26923        duplicate.active_session = None;
26924        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26925        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26926        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26927        assert_eq!(shared.snapshot().session_records.len(), 1);
26928
26929        let terminal_size = gate4agent_types::TerminalSize {
26930            rows: 24,
26931            columns: 80,
26932        };
26933        let (first_busy, second_busy) = tokio::join!(
26934            shared.resume_session_record(&durable_id, terminal_size, None),
26935            shared.resume_session_record(&durable_id, terminal_size, None),
26936        );
26937        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26938            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26939            assert_eq!(busy.message, "session-record-busy");
26940        }
26941
26942        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26943        assert_eq!(loaded.records.len(), 1);
26944        assert_eq!(loaded.records[0].record_id, durable_id);
26945        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26946        assert_eq!(
26947            loaded.records[0]
26948                .provider_session
26949                .as_ref()
26950                .unwrap()
26951                .transcript_path,
26952            None,
26953        );
26954        let catalog = active_registry().unwrap();
26955        let (reloaded_handle, _reloaded_runtime) =
26956            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26957        let reloaded = NodeShared::new_with_incarnation(
26958            reloaded_handle,
26959            "fixture-token".to_owned(),
26960            node_id,
26961            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26962            vec![workspace],
26963            vec![agent("claude")],
26964            ProviderRuntimeStatuses::default(),
26965            None,
26966            Vec::new(),
26967            Vec::new(),
26968            SpawnProfileRegistry::default(),
26969            None,
26970            None,
26971            None,
26972            None,
26973            loaded.records,
26974            loaded.managed_worktrees,
26975            loaded.managed_worktree_tombstones,
26976            loaded.materializations,
26977            loaded.warning,
26978            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26979        );
26980        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26981        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26982        std::fs::remove_dir_all(root).unwrap();
26983    }
26984
26985    #[tokio::test]
26986    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26987        let secondary_root = temporary_workspace_root("workspace-restore");
26988        let other_root = temporary_workspace_root("workspace-conflict");
26989        std::fs::create_dir_all(&secondary_root).unwrap();
26990        std::fs::create_dir_all(&other_root).unwrap();
26991        let catalog = active_registry().unwrap();
26992        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26993        let primary = WorkspaceConfig::new(
26994            WorkspaceId::new("primary").unwrap(),
26995            std::env::current_dir().unwrap(),
26996        )
26997        .unwrap();
26998        let secondary = WorkspaceConfig::new(
26999            WorkspaceId::new("secondary").unwrap(),
27000            &secondary_root,
27001        )
27002        .unwrap();
27003        let shared = NodeShared::new(
27004            handle,
27005            "fixture-token".to_owned(),
27006            NodeId::new("node-1").unwrap(),
27007            vec![primary, secondary.clone()],
27008            vec![agent("claude")],
27009        );
27010        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27011        shared
27012            .insert_record(ManagedSessionRecord {
27013                record_id: record_id.clone(),
27014                display_name: "workspace record".to_owned(),
27015                provider: agent("claude"),
27016                mode: SessionMode::Pty,
27017                state: ManagedSessionState::Dormant,
27018                workspace_id: secondary.workspace_id.clone(),
27019                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27020                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27021                    key: gate4agent_types::ProviderSessionKey::SessionId,
27022                    id: "provider-session".to_owned(),
27023                    transcript_path: None,
27024                }),
27025                active_session: None,
27026                environment_profile: None,
27027                bundle: None,
27028                context_id: None,
27029                context: None,
27030                exported_context: None,
27031                task_binding: None,
27032                created_at_unix_ms: 1,
27033                updated_at_unix_ms: 1,
27034                last_error: None,
27035            })
27036            .unwrap();
27037        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27038        let unavailable = shared.record(&record_id).unwrap();
27039        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27040        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27041
27042        let conflict = shared
27043            .register_workspace(
27044                secondary.workspace_id.clone(),
27045                other_root.to_string_lossy().into_owned(),
27046            )
27047            .await
27048            .unwrap_err();
27049        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27050        shared
27051            .register_workspace(
27052                secondary.workspace_id,
27053                secondary_root.to_string_lossy().into_owned(),
27054            )
27055            .await
27056            .unwrap();
27057        let restored = shared.record(&record_id).unwrap();
27058        assert_eq!(restored.state, ManagedSessionState::Dormant);
27059        assert!(restored.last_error.is_none());
27060        std::fs::remove_dir_all(secondary_root).unwrap();
27061        std::fs::remove_dir_all(other_root).unwrap();
27062    }
27063
27064    #[test]
27065    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27066        let error = validate_workspace_request_root(
27067            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27068        )
27069        .unwrap_err();
27070        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27071        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27072        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27073    }
27074
27075    #[test]
27076    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27077        let catalog = active_registry().unwrap();
27078        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27079        let workspace = WorkspaceConfig::new(
27080            WorkspaceId::new("primary").unwrap(),
27081            std::env::current_dir().unwrap(),
27082        )
27083        .unwrap();
27084        let shared = NodeShared::new(
27085            handle,
27086            "fixture-token".to_owned(),
27087            NodeId::new("node-1").unwrap(),
27088            vec![workspace],
27089            vec![agent("claude")],
27090        );
27091        let publishers = 4_u64;
27092        let events_per_publisher = 256_u64;
27093        let start = std::sync::Barrier::new(publishers as usize + 1);
27094        let finished = AtomicU64::new(0);
27095
27096        std::thread::scope(|scope| {
27097            for _ in 0..publishers {
27098                scope.spawn(|| {
27099                    start.wait();
27100                    for _ in 0..events_per_publisher {
27101                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27102                    }
27103                    finished.fetch_add(1, Ordering::Release);
27104                });
27105            }
27106            start.wait();
27107            while finished.load(Ordering::Acquire) != publishers {
27108                assert_resync_history_is_linearized(shared.resync(0));
27109                std::thread::yield_now();
27110            }
27111        });
27112        let response = shared.resync(0);
27113        assert_resync_history_is_linearized(response.clone());
27114        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27115            unreachable!("resync helper returned another response");
27116        };
27117        assert_eq!(event_sequence, publishers * events_per_publisher);
27118        assert_eq!(events.len() as u64, event_sequence);
27119    }
27120
27121    fn assert_resync_history_is_linearized(response: NodeResponse) {
27122        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27123            unreachable!("resync helper returned another response");
27124        };
27125        assert!(events.windows(2).all(|pair| {
27126            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27127        }));
27128        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27129    }
27130
27131    #[test]
27132    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27133        assert_eq!(
27134            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27135            r"C:\repo\worktree",
27136        );
27137        assert_eq!(
27138            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27139            r"\\server\share\repo",
27140        );
27141        assert!(validate_workspace_root(
27142            &WorkspaceId::new("network").unwrap(),
27143            r"\\server\share\repo",
27144        )
27145        .is_err());
27146        let workspace = WorkspaceConfig::new(
27147            WorkspaceId::new("primary").unwrap(),
27148            std::env::current_dir().unwrap(),
27149        )
27150        .unwrap();
27151        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27152    }
27153
27154    #[test]
27155    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27156        let catalog = active_registry().unwrap();
27157        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27158        let workspace_id = WorkspaceId::new("primary").unwrap();
27159        let workspace = WorkspaceConfig::new(
27160            workspace_id.clone(),
27161            std::env::current_dir().unwrap(),
27162        )
27163        .unwrap();
27164        let shared = NodeShared::new(
27165            handle,
27166            "fixture-token".to_owned(),
27167            NodeId::new("node-1").unwrap(),
27168            vec![workspace],
27169            vec![agent("claude")],
27170        );
27171        let instance_id = AgentInstanceId(17);
27172        let original = SessionAddress {
27173            workspace_id,
27174            session: SessionKey {
27175                instance_id,
27176                generation: SessionGeneration(3),
27177            },
27178        };
27179        shared.bind_session(
27180            instance_id,
27181            original.workspace_id.clone(),
27182            original.session.generation,
27183        );
27184        shared
27185            .arm_resume(
27186                &original,
27187                CommandId(41),
27188                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27189            )
27190            .unwrap();
27191
27192        shared.publish_control(ControlEvent {
27193            sequence: 1,
27194            command_id: None,
27195            instance_id,
27196            generation: SessionGeneration(4),
27197            event: ControlEventKind::Running { process_id: None },
27198        });
27199        assert_eq!(
27200            shared.address_for(instance_id, SessionGeneration(3)),
27201            Some(original.clone()),
27202        );
27203        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27204
27205        shared.publish_control(ControlEvent {
27206            sequence: 2,
27207            command_id: Some(CommandId(42)),
27208            instance_id,
27209            generation: SessionGeneration(3),
27210            event: ControlEventKind::CommandRejected {
27211                message: "unrelated command".to_owned(),
27212            },
27213        });
27214
27215        shared.publish_control(ControlEvent {
27216            sequence: 3,
27217            command_id: None,
27218            instance_id,
27219            generation: SessionGeneration(4),
27220            event: ControlEventKind::ResumeAuthorized {
27221                session: gate4agent_types::ResumeSessionSummary {
27222                    key: gate4agent_types::ProviderSessionKey::SessionId,
27223                    id: "fixture-session".to_owned(),
27224                },
27225            },
27226        });
27227        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27228        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27229        assert_eq!(rebound.workspace_id, original.workspace_id);
27230        let history = shared
27231            .history
27232            .lock()
27233            .unwrap_or_else(|poisoned| poisoned.into_inner());
27234        assert!(history.events.iter().any(|envelope| matches!(
27235            &envelope.event,
27236            NodeEvent::Control { address, event }
27237                if address == &rebound
27238                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27239        )));
27240    }
27241
27242    #[test]
27243    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27244        for agent in ["claude", "codex", "kimi"] {
27245            assert_eq!(
27246                prompt_framing(&AgentId::new(agent).unwrap()),
27247                PromptFraming::BracketedPaste
27248            );
27249        }
27250    }
27251
27252    fn generous_walk_deadline() -> Instant {
27253        Instant::now() + Duration::from_secs(30)
27254    }
27255
27256    #[test]
27257    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27258        let root = temporary_workspace_root("tree");
27259        std::fs::create_dir_all(root.join("src")).unwrap();
27260        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27261        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27262        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27263        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27264        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27265        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27266        // real repository's own build layout) that carries the Cache
27267        // Directory Tagging Specification marker cargo writes into every
27268        // `--target-dir` it creates: must be skipped even though its name
27269        // does not exactly match `target`.
27270        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27271        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27272        // A directory that merely *starts with* `target` but carries no
27273        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27274        // on the name alone, so this stays in the response.
27275        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27276        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27277
27278        let (entries, truncated, _budget) =
27279            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27280        assert!(!truncated);
27281        assert!(entries.iter().any(|entry| {
27282            entry.relative_path.as_utf8() == Some("src")
27283                && entry.kind == WorkspaceEntryKind::Directory
27284        }));
27285        assert!(entries.iter().any(|entry| {
27286            entry.relative_path.as_utf8() == Some("src/lib.rs")
27287                && entry.kind == WorkspaceEntryKind::File
27288        }));
27289        assert!(entries.iter().any(|entry| {
27290            entry.relative_path.as_utf8() == Some("target-notes")
27291                && entry.kind == WorkspaceEntryKind::Directory
27292        }));
27293        assert!(entries.iter().any(|entry| {
27294            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27295                && entry.kind == WorkspaceEntryKind::File
27296        }));
27297        assert!(entries.iter().all(|entry| {
27298            let path = entry.relative_path.as_utf8().unwrap();
27299            !path.starts_with(".git")
27300                && !(path == "target" || path.starts_with("target/"))
27301                && !(path == "target-a2" || path.starts_with("target-a2/"))
27302                && !path.starts_with("node_modules")
27303        }));
27304
27305        std::fs::remove_dir_all(&root).unwrap();
27306    }
27307
27308    #[test]
27309    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27310        let root = temporary_workspace_root("tree-entry-cap");
27311        std::fs::create_dir_all(&root).unwrap();
27312        for index in 0..20 {
27313            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27314        }
27315
27316        let (entries, truncated, budget) =
27317            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27318        assert!(truncated);
27319        assert!(budget.entry_cap_exceeded);
27320        assert!(!budget.time_budget_exceeded);
27321        assert!(entries.len() <= 5);
27322
27323        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27324            .expect("an entry-cap-truncated walk must report a truncation marker");
27325        assert!(inspection_truncation.walk_entry_cap_exceeded);
27326        assert!(!inspection_truncation.walk_time_budget_exceeded);
27327        assert!(!inspection_truncation.git_time_budget_exceeded);
27328        assert!(inspection_truncation.entries_visited >= 5);
27329
27330        std::fs::remove_dir_all(&root).unwrap();
27331    }
27332
27333    #[test]
27334    fn workspace_walk_within_budget_reports_no_truncation() {
27335        let root = temporary_workspace_root("tree-no-truncation");
27336        std::fs::create_dir_all(&root).unwrap();
27337        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27338
27339        let (_entries, truncated, budget) =
27340            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27341        assert!(!truncated);
27342        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27343
27344        std::fs::remove_dir_all(&root).unwrap();
27345    }
27346
27347    #[test]
27348    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27349        let root = temporary_workspace_root("tree-prefix-siblings");
27350        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27351        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27352        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27353        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27354
27355        let (entries, truncated, _budget) =
27356            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27357        assert!(!truncated);
27358        let paths = entries
27359            .iter()
27360            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27361            .collect::<Vec<_>>();
27362        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27363        assert_eq!(
27364            paths,
27365            vec![
27366                "gate4agent-c2".to_owned(),
27367                "gate4agent-c2-client".to_owned(),
27368                "gate4agent-c2-client/lib.rs".to_owned(),
27369                "gate4agent-c2/src".to_owned(),
27370                "gate4agent-c2/src/lib.rs".to_owned(),
27371            ],
27372        );
27373
27374        std::fs::remove_dir_all(&root).unwrap();
27375    }
27376
27377    #[test]
27378    fn git_parsers_cap_status_and_commit_results() {
27379        let mut status_output = Vec::new();
27380        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27381            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27382        }
27383        let mut snapshot = git_snapshot_for_parser();
27384        parse_git_status(&status_output, &mut snapshot);
27385        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27386        assert!(snapshot.truncated);
27387        assert_eq!(snapshot.status[0].index_status, " ");
27388        assert_eq!(snapshot.status[0].worktree_status, "M");
27389
27390        snapshot.truncated = false;
27391        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27392            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27393            .collect::<String>();
27394        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27395        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27396        assert!(snapshot.truncated);
27397    }
27398
27399    #[test]
27400    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27401        let mut snapshot = git_snapshot_for_parser();
27402        parse_git_status(
27403            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27404            &mut snapshot,
27405        );
27406
27407        assert!(!snapshot.truncated);
27408        assert_eq!(snapshot.status.len(), 3);
27409        assert_eq!(snapshot.status[0].index_status, "R");
27410        assert_eq!(snapshot.status[0].worktree_status, " ");
27411        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27412        assert_eq!(
27413            snapshot.status[0]
27414                .previous_path
27415                .as_ref()
27416                .and_then(RepositoryPath::as_utf8),
27417            Some("src/previous name.rs"),
27418        );
27419        assert_eq!(snapshot.status[1].index_status, " ");
27420        assert_eq!(snapshot.status[1].worktree_status, "C");
27421        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27422        assert_eq!(
27423            snapshot.status[1]
27424                .previous_path
27425                .as_ref()
27426                .and_then(RepositoryPath::as_utf8),
27427            Some("src/original.rs"),
27428        );
27429        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27430        assert!(snapshot.status[2].previous_path.is_none());
27431    }
27432
27433    #[test]
27434    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27435        let mut snapshot = git_snapshot_for_parser();
27436        parse_git_status(
27437            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27438            &mut snapshot,
27439        );
27440
27441        assert!(snapshot.truncated);
27442        assert_eq!(snapshot.status.len(), 1);
27443        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27444        assert!(snapshot.status[0].previous_path.is_none());
27445    }
27446
27447    #[tokio::test]
27448    async fn observer_can_browse_host_directories_without_controller() {
27449        let root = temporary_workspace_root("host-directory-observer");
27450        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27451        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27452        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27453        let workspace_id = WorkspaceId::new("primary").unwrap();
27454        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27455        let catalog = active_registry().unwrap();
27456        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27457        let shared = NodeShared::new(
27458            handle,
27459            "fixture-token".to_owned(),
27460            NodeId::new("node-1").unwrap(),
27461            vec![workspace],
27462            vec![agent("claude")],
27463        );
27464        let request = NodeRequest::BrowseHostDirectories {
27465            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27466            after: None,
27467        };
27468        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27469            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27470        }));
27471        assert!(!request_uses_unnegotiated_capability(
27472            &request,
27473            &baseline_capabilities().unwrap(),
27474        ));
27475        assert!(request_uses_unnegotiated_capability(&request, &[]));
27476
27477        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27478            .await
27479            .unwrap();
27480        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27481            panic!("host directory browse returned another response");
27482        };
27483        assert_eq!(
27484            listing
27485                .entries
27486                .iter()
27487                .map(|entry| entry.display_name.as_str())
27488                .collect::<Vec<_>>(),
27489            vec!["directory-a", "directory-b"],
27490        );
27491        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27492        assert!(!listing.incomplete);
27493        std::fs::remove_dir_all(root).unwrap();
27494    }
27495
27496    #[tokio::test]
27497    async fn observer_can_inspect_only_a_registered_workspace() {
27498        let root = temporary_workspace_root("observer");
27499        std::fs::create_dir_all(root.join("src")).unwrap();
27500        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27501        let workspace_id = WorkspaceId::new("primary").unwrap();
27502        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27503        let catalog = active_registry().unwrap();
27504        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27505        let shared = NodeShared::new(
27506            handle,
27507            "fixture-token".to_owned(),
27508            NodeId::new("node-1").unwrap(),
27509            vec![workspace],
27510            vec![agent("claude")],
27511        );
27512
27513        let response = process_request_inner(
27514            &shared,
27515            77,
27516            ClientRole::Observer,
27517            NodeRequest::InspectWorkspace {
27518                workspace_id: workspace_id.clone(),
27519            },
27520        )
27521        .await
27522        .unwrap();
27523        let NodeResponse::WorkspaceInspected { inspection } = response else {
27524            panic!("workspace inspection returned another response");
27525        };
27526        assert_eq!(inspection.workspace_id, workspace_id);
27527        assert!(inspection.entries.iter().any(|entry| {
27528            entry.relative_path.as_utf8() == Some("src/main.rs")
27529        }));
27530
27531        let unknown = process_request_inner(
27532            &shared,
27533            77,
27534            ClientRole::Observer,
27535            NodeRequest::InspectWorkspace {
27536                workspace_id: WorkspaceId::new("unknown").unwrap(),
27537            },
27538        )
27539        .await
27540        .unwrap_err();
27541        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27542        std::fs::remove_dir_all(&root).unwrap();
27543    }
27544
27545    #[test]
27546    fn context_pack_source_accepts_only_running_or_successful_exit() {
27547        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27548        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27549            exit_code: Some(0),
27550        }));
27551        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27552            exit_code: Some(1),
27553        }));
27554        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27555            exit_code: None,
27556        }));
27557        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27558            message: "provider failed".to_owned(),
27559        }));
27560        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27561        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27562        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27563    }
27564
27565    #[test]
27566    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27567        let git = git_snapshot_for_parser();
27568        let first = ContextPackRepositoryObservation {
27569            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27570            git: git.clone(),
27571            files: Vec::new(),
27572        };
27573        let second = ContextPackRepositoryObservation {
27574            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27575            git,
27576            files: Vec::new(),
27577        };
27578
27579        let error = stable_context_pack_repository(first, second).unwrap_err();
27580
27581        assert_eq!(
27582            error.code,
27583            NodeFailureCode::ContextPackMaterializationFailed,
27584        );
27585    }
27586
27587    #[test]
27588    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27589        let git = git_snapshot_for_parser();
27590        let first = ContextPackRepositoryObservation {
27591            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27592            git: git.clone(),
27593            files: vec![ContextPackRepositoryFileSource::utf8(
27594                "README.md",
27595                "first\n".to_owned(),
27596                6,
27597            )],
27598        };
27599        let second = ContextPackRepositoryObservation {
27600            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27601            git,
27602            files: vec![ContextPackRepositoryFileSource::utf8(
27603                "README.md",
27604                "other\n".to_owned(),
27605                6,
27606            )],
27607        };
27608
27609        let error = stable_context_pack_repository(first, second).unwrap_err();
27610
27611        assert_eq!(
27612            error.code,
27613            NodeFailureCode::ContextPackMaterializationFailed,
27614        );
27615    }
27616
27617    #[tokio::test]
27618    async fn native_session_catalog_zero_active_session_fixture() {
27619        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27620        let history_root = temporary_workspace_root("native-catalog-history");
27621        let outside_root = temporary_workspace_root("native-catalog-outside");
27622        std::fs::create_dir_all(&workspace_root).unwrap();
27623        std::fs::create_dir_all(&history_root).unwrap();
27624        std::fs::create_dir_all(&outside_root).unwrap();
27625        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27626            [
27627                serde_json::json!({
27628                    "type": "user",
27629                    "sessionId": session_id,
27630                    "cwd": cwd.to_str().unwrap(),
27631                    "message": { "content": "raw user text must not cross the catalog wire" }
27632                }),
27633                serde_json::json!({
27634                    "type": "assistant",
27635                    "sessionId": session_id,
27636                    "cwd": cwd.to_str().unwrap(),
27637                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27638                }),
27639                serde_json::json!({
27640                    "type": "ai-title",
27641                    "sessionId": session_id,
27642                    "aiTitle": title
27643                }),
27644            ]
27645            .into_iter()
27646            .map(|record| record.to_string())
27647            .collect::<Vec<_>>()
27648            .join("\n")
27649        };
27650        std::fs::write(
27651            history_root.join("stable-session.jsonl"),
27652            transcript("stable-session", &workspace_root, "Workspace review"),
27653        )
27654        .unwrap();
27655        std::fs::write(
27656            history_root.join("outside-session.jsonl"),
27657            transcript("outside-session", &outside_root, "Outside review"),
27658        )
27659        .unwrap();
27660        let history = NativeHistoryConfig::new(vec![
27661            NativeHistoryRoot::new(
27662                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27663                HistorySourceLayout::SingleNdjson,
27664                history_root.clone(),
27665            )
27666            .unwrap(),
27667        ])
27668        .unwrap();
27669        let catalog = active_registry().unwrap();
27670        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27671        assert!(handle.snapshot().sessions.is_empty());
27672        let workspace_id = WorkspaceId::new("primary").unwrap();
27673        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27674        let mut shared = NodeShared::new(
27675            handle,
27676            "fixture-token".to_owned(),
27677            NodeId::new("node-native-catalog").unwrap(),
27678            vec![workspace],
27679            vec![agent("claude")],
27680        );
27681        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27682            NativeSessionCatalogAuthority::new(history),
27683        )));
27684        let route = NativeSessionCatalogRoute::workspace(
27685            workspace_id.clone(),
27686            agent("claude"),
27687        );
27688        let request = NodeRequest::CatalogNativeSessions {
27689            route: route.clone(),
27690            limit: 2,
27691        };
27692        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27693            .await
27694            .unwrap_err();
27695        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27696        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27697            .await
27698            .unwrap();
27699        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27700            panic!("native catalog returned a different response");
27701        };
27702        let summary = summary.expect("node must emit native session catalog summary metadata");
27703        assert_eq!(entries.len(), 1);
27704        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27705        assert_eq!(entries[0].external_group, None);
27706        assert_eq!(entries[0].record_id, None);
27707        assert_eq!(entries[0].title, None);
27708        assert_eq!(entries[0].model, None);
27709        assert_eq!(entries[0].message_count, 0);
27710        let encoded_entries = serde_json::to_string(&entries).unwrap();
27711        assert!(!encoded_entries.contains("stable-session"));
27712        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27713        let page_request = NodeRequest::PageNativeSessions {
27714            route: route.clone(),
27715            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27716            catalog_revision: summary.catalog_revision,
27717            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27718            after_selection_id: None,
27719            limit: 2,
27720        };
27721        let observer = process_request_inner(
27722            &shared,
27723            7,
27724            ClientRole::Observer,
27725            page_request.clone(),
27726        )
27727        .await
27728        .unwrap_err();
27729        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27730        let page_response = process_request_inner(
27731            &shared,
27732            7,
27733            ClientRole::Operator,
27734            page_request.clone(),
27735        )
27736        .await
27737        .unwrap();
27738        assert!(matches!(
27739            page_response,
27740            NodeResponse::NativeSessionsPaged { page, .. }
27741                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27742        ));
27743        let stale = process_request_inner(
27744            &shared,
27745            7,
27746            ClientRole::Operator,
27747            NodeRequest::PageNativeSessions {
27748                route: route.clone(),
27749                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27750                catalog_revision: summary.catalog_revision.wrapping_add(1),
27751                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27752                after_selection_id: None,
27753                limit: 2,
27754            },
27755        )
27756        .await
27757        .unwrap_err();
27758        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27759        let selection = NativeSessionSelection {
27760            route: route.clone(),
27761            catalog_revision: summary.catalog_revision,
27762            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27763            selection_id: entries[0].selection_id.clone(),
27764        };
27765        let preview_response = process_request_inner(
27766            &shared,
27767            7,
27768            ClientRole::Operator,
27769            NodeRequest::PreviewNativeSession {
27770                selection: selection.clone(),
27771                message_limit: 1,
27772            },
27773        )
27774        .await
27775        .unwrap();
27776        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27777            panic!("native preview returned a different response");
27778        };
27779        assert_eq!(preview.messages.len(), 1);
27780        assert!(preview.message_count_exact);
27781        assert_eq!(preview.messages[0].text, "raw answer");
27782        assert!(preview.truncated);
27783
27784        let index_request = NodeRequest::IndexNativeSession {
27785            selection: selection.clone(),
27786            display_name: "Saved Claude".to_owned(),
27787        };
27788        let observer = process_request_inner(
27789            &shared,
27790            7,
27791            ClientRole::Observer,
27792            index_request.clone(),
27793        )
27794        .await
27795        .unwrap_err();
27796        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27797        let without_controller = process_request_inner(
27798            &shared,
27799            7,
27800            ClientRole::Operator,
27801            index_request.clone(),
27802        )
27803        .await
27804        .unwrap_err();
27805        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27806        let acquired = process_request_inner(
27807            &shared,
27808            7,
27809            ClientRole::Operator,
27810            NodeRequest::AcquireController { lease_ms: 5_000 },
27811        )
27812        .await
27813        .unwrap();
27814        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27815        let indexed = process_request_inner(
27816            &shared,
27817            7,
27818            ClientRole::Operator,
27819            index_request,
27820        )
27821        .await
27822        .unwrap();
27823        assert!(indexed.native_session_index_contract_is_valid());
27824        let NodeResponse::NativeSessionIndexed {
27825            selection: echoed_selection,
27826            record,
27827        } = indexed else {
27828            panic!("native session index returned a different response");
27829        };
27830        assert_eq!(echoed_selection, selection);
27831        let recataloged = process_request_inner(
27832            &shared,
27833            7,
27834            ClientRole::Operator,
27835            NodeRequest::CatalogNativeSessions {
27836                route,
27837                limit: 2,
27838            },
27839        )
27840        .await
27841        .unwrap();
27842        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27843            panic!("native catalog returned a different response after indexing");
27844        };
27845        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27846
27847        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27848        let external_catalog = process_request_inner(
27849            &shared,
27850            7,
27851            ClientRole::Operator,
27852            NodeRequest::CatalogNativeSessions {
27853                route: unregistered_route.clone(),
27854                limit: 2,
27855            },
27856        )
27857        .await
27858        .unwrap();
27859        let external_json = serde_json::to_string(&external_catalog).unwrap();
27860        assert!(!external_json.contains("outside-session"));
27861        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27862        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27863        assert!(!external_json.contains("\"session_id\""));
27864        assert!(!external_json.contains("\"cwd\""));
27865        assert!(!external_json.contains("\"path\""));
27866        let NodeResponse::NativeSessionsCataloged {
27867            route: echoed_route,
27868            entries: external_entries,
27869            summary: Some(external_summary),
27870        } = external_catalog
27871        else {
27872            panic!("unregistered native catalog returned a different response");
27873        };
27874        assert_eq!(echoed_route, unregistered_route);
27875        assert_eq!(external_entries.len(), 1);
27876        assert_eq!(external_entries[0].record_id, None);
27877        let external_group = external_entries[0]
27878            .external_group
27879            .as_ref()
27880            .expect("unregistered session must have an external group");
27881        assert_eq!(
27882            external_group.kind,
27883            gate4agent_types::NativeSessionExternalGroupKind::Project,
27884        );
27885        assert!(external_group.group_id.starts_with("external-"));
27886        assert_eq!(
27887            external_group.display_name,
27888            outside_root.file_name().unwrap().to_string_lossy(),
27889        );
27890        let external_selection = NativeSessionSelection {
27891            route: unregistered_route,
27892            catalog_revision: external_summary.catalog_revision,
27893            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27894            selection_id: external_entries[0].selection_id.clone(),
27895        };
27896        let external_preview = process_request_inner(
27897            &shared,
27898            7,
27899            ClientRole::Operator,
27900            NodeRequest::PreviewNativeSession {
27901                selection: external_selection.clone(),
27902                message_limit: 1,
27903            },
27904        )
27905        .await
27906        .unwrap();
27907        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27908        assert!(!external_preview_json.contains("outside-session"));
27909        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27910        assert!(!external_preview_json.contains("\"session_id\""));
27911        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27912            panic!("unregistered native preview returned a different response");
27913        };
27914        assert_eq!(preview.messages.len(), 1);
27915        let external_index = process_request_inner(
27916            &shared,
27917            7,
27918            ClientRole::Operator,
27919            NodeRequest::IndexNativeSession {
27920                selection: external_selection,
27921                display_name: "External Claude".to_owned(),
27922            },
27923        )
27924        .await
27925        .unwrap_err();
27926        assert_eq!(
27927            external_index.code,
27928            NodeFailureCode::WorkspaceRegistrationRequired,
27929        );
27930        let record_response = process_request_inner(
27931            &shared,
27932            7,
27933            ClientRole::Operator,
27934            NodeRequest::PreviewSessionRecord {
27935                record_id: record.record_id.clone(),
27936                message_limit: 2,
27937            },
27938        )
27939        .await
27940        .unwrap();
27941        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27942            panic!("record preview returned a different response");
27943        };
27944        assert_eq!(record_id, record.record_id);
27945        assert_eq!(preview.messages.len(), 2);
27946        let encoded = serde_json::to_string(&preview).unwrap();
27947        assert!(!encoded.contains("stable-session"));
27948        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27949        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27950            panic!("record preview history summary returned a different response");
27951        };
27952        let summaries = events
27953            .iter()
27954            .filter_map(|envelope| match &envelope.event {
27955                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27956                    if record_id == &record.record_id =>
27957                {
27958                    Some(*summary)
27959                }
27960                _ => None,
27961            })
27962            .collect::<Vec<_>>();
27963        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27964        let summary_json = serde_json::to_string(&summaries).unwrap();
27965        for private in [
27966            "stable-session",
27967            "raw user text must not cross the catalog wire",
27968            "raw answer",
27969            "Workspace review",
27970            "claude-sonnet",
27971        ] {
27972            assert!(!summary_json.contains(private), "{private}");
27973        }
27974        let identity = record.provider_session.clone().unwrap();
27975        assert_eq!(
27976            shared.revalidate_session_record_preview(&record, &identity),
27977            Ok(())
27978        );
27979        shared.remove_record_memory(&record.record_id);
27980        assert_eq!(
27981            shared
27982                .revalidate_session_record_preview(&record, &identity)
27983                .unwrap_err()
27984                .code,
27985            NodeFailureCode::UnknownSessionRecord
27986        );
27987        let released = process_request_inner(
27988            &shared,
27989            7,
27990            ClientRole::Operator,
27991            NodeRequest::ReleaseController,
27992        )
27993        .await
27994        .unwrap();
27995        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27996        assert!(shared.controller_state().is_none());
27997        std::fs::remove_dir_all(workspace_root).unwrap();
27998        std::fs::remove_dir_all(history_root).unwrap();
27999        std::fs::remove_dir_all(outside_root).unwrap();
28000    }
28001
28002    #[tokio::test]
28003    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28004        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28005        std::fs::create_dir_all(&history_root).unwrap();
28006        let history = NativeHistoryConfig::new(vec![
28007            NativeHistoryRoot::new(
28008                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28009                HistorySourceLayout::SingleNdjson,
28010                history_root.clone(),
28011            )
28012            .unwrap(),
28013        ])
28014        .unwrap();
28015        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28016        let held = catalog.lock().unwrap();
28017        let result = timeout(
28018            Duration::from_secs(1),
28019            run_native_session_catalog_operation(
28020                Arc::clone(&catalog),
28021                "native session catalog",
28022                |_| Ok::<(), std::convert::Infallible>(()),
28023            ),
28024        )
28025        .await
28026        .expect("concurrent native catalog request must not wait for the operation deadline")
28027        .unwrap_err();
28028        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28029        drop(held);
28030        std::fs::remove_dir_all(history_root).unwrap();
28031    }
28032
28033    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28034        let unique = std::time::SystemTime::now()
28035            .duration_since(std::time::UNIX_EPOCH)
28036            .unwrap()
28037            .as_nanos();
28038        std::env::temp_dir().join(format!(
28039            "gate4agent-node-{label}-{}-{unique}",
28040            std::process::id(),
28041        ))
28042    }
28043
28044    fn git_snapshot_for_parser() -> GitSnapshot {
28045        GitSnapshot {
28046            is_repository: true,
28047            branch: Some("main".to_owned()),
28048            status: Vec::new(),
28049            recent_commits: Vec::new(),
28050            worktrees: Vec::new(),
28051            managed_worktree: None,
28052            truncated: false,
28053            diagnostic: None,
28054        }
28055    }
28056
28057    #[tokio::test]
28058    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28059        let root = temporary_workspace_root("managed-git-scope");
28060        std::fs::create_dir_all(&root).unwrap();
28061        run_test_git(&root, &["init"]);
28062        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28063        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28064        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28065        run_test_git(&root, &["add", "seed.txt"]);
28066        run_test_git(&root, &["commit", "-m", "seed"]);
28067        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28068
28069        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28070        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28071        let catalog = active_registry().unwrap();
28072        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28073        let shared = NodeShared::new(
28074            handle,
28075            "fixture-token".to_owned(),
28076            NodeId::new("node-1").unwrap(),
28077            vec![workspace],
28078            vec![agent("claude")],
28079        );
28080        let mut lease = ManagedWorktreeLeaseRecord {
28081            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28082            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28083            workspace_id: workspace_id.clone(),
28084            profile_id: WorktreeProfileId::new("default").unwrap(),
28085            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28086            target_root: root.to_string_lossy().into_owned(),
28087            branch: "gate4agent/a".to_owned(),
28088            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28089            expected_head: None,
28090            retention: ManagedWorktreeRetention::Retain,
28091            state: ManagedWorktreeLeaseState::InUse,
28092            session_holders: vec![ManagedWorktreeSessionHolder {
28093                incarnation_id: shared.incarnation_id,
28094                instance_id: AgentInstanceId(7),
28095                generation: SessionGeneration(1),
28096            }],
28097            record_holders: Vec::new(),
28098            cleanup_failure: None,
28099            created_at_unix_ms: 1,
28100            updated_at_unix_ms: 1,
28101        };
28102        *shared
28103            .managed_worktrees
28104            .lock()
28105            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28106            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28107
28108        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28109        let scope = inspection.git.managed_worktree.unwrap();
28110        assert_eq!(scope.lease_id, lease.lease_id);
28111        assert_eq!(scope.branch, "gate4agent/a");
28112        assert_eq!(scope.active_session_count, 1);
28113
28114        lease.branch = "gate4agent/b".to_owned();
28115        *shared
28116            .managed_worktrees
28117            .lock()
28118            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28119            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28120        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28121        assert!(mismatch.git.managed_worktree.is_none());
28122        std::fs::remove_dir_all(root).unwrap();
28123    }
28124
28125    #[tokio::test]
28126    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28127        let root = temporary_workspace_root("workspace-file-write-controller");
28128        std::fs::create_dir_all(root.join("src")).unwrap();
28129        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28130        let workspace_id = WorkspaceId::new("primary").unwrap();
28131        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28132        let catalog = active_registry().unwrap();
28133        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28134        let shared = NodeShared::new(
28135            handle,
28136            "fixture-token".to_owned(),
28137            NodeId::new("node-1").unwrap(),
28138            vec![workspace],
28139            vec![agent("claude")],
28140        );
28141        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28142        let expected_revision = workspace_file_revision(b"old\n");
28143        let request = NodeRequest::WriteWorkspaceFile {
28144            workspace_id: workspace_id.clone(),
28145            path: path.clone(),
28146            expected_revision: expected_revision.clone(),
28147            text: "new\n".to_owned(),
28148        };
28149        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28150            .await
28151            .unwrap_err();
28152        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28153        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28154            .await
28155            .unwrap_err();
28156        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28157        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28158        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28159        let NodeResponse::WorkspaceFileWritten { file } = response else {
28160            panic!("workspace file write returned another response");
28161        };
28162        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28163        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28164        let stale = process_request_inner(
28165            &shared,
28166            77,
28167            ClientRole::Operator,
28168            NodeRequest::WriteWorkspaceFile {
28169                workspace_id,
28170                path,
28171                expected_revision,
28172                text: "lost update\n".to_owned(),
28173            },
28174        )
28175        .await
28176        .unwrap_err();
28177        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28178        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28179        std::fs::remove_dir_all(root).unwrap();
28180    }
28181
28182    #[tokio::test]
28183    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28184        let root = temporary_workspace_root("workspace-entry-create-controller");
28185        std::fs::create_dir_all(root.join("src")).unwrap();
28186        let workspace_id = WorkspaceId::new("primary").unwrap();
28187        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28188        let catalog = active_registry().unwrap();
28189        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28190        let shared = NodeShared::new(
28191            handle,
28192            "fixture-token".to_owned(),
28193            NodeId::new("node-1").unwrap(),
28194            vec![workspace],
28195            vec![agent("claude")],
28196        );
28197        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28198        let request = NodeRequest::CreateWorkspaceFile {
28199            workspace_id: workspace_id.clone(),
28200            path: path.clone(),
28201        };
28202        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28203            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28204        }));
28205        assert!(!request_uses_unnegotiated_capability(
28206            &request,
28207            &baseline_capabilities().unwrap(),
28208        ));
28209        assert!(request_uses_unnegotiated_capability(&request, &[]));
28210        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28211            .await
28212            .unwrap_err();
28213        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28214        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28215            .await
28216            .unwrap_err();
28217        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28218        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28219
28220        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28221            .await
28222            .unwrap();
28223        let NodeResponse::WorkspaceFileCreated { file } = response else {
28224            panic!("workspace file create returned another response");
28225        };
28226        assert_eq!(file.workspace_id, workspace_id);
28227        assert_eq!(file.path, path);
28228        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28229        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28230
28231        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28232            .await
28233            .unwrap_err();
28234        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28235
28236        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28237        let response = process_request_inner(
28238            &shared,
28239            77,
28240            ClientRole::Operator,
28241            NodeRequest::CreateWorkspaceDirectory {
28242                workspace_id: workspace_id.clone(),
28243                path: directory_path.clone(),
28244            },
28245        )
28246        .await
28247        .unwrap();
28248        assert_eq!(
28249            response,
28250            NodeResponse::WorkspaceDirectoryCreated {
28251                workspace_id: workspace_id.clone(),
28252                entry: WorkspaceEntry {
28253                    relative_path: directory_path,
28254                    kind: WorkspaceEntryKind::Directory,
28255                },
28256            },
28257        );
28258        assert!(root.join("src/nested").is_dir());
28259
28260        let missing_parent = process_request_inner(
28261            &shared,
28262            77,
28263            ClientRole::Operator,
28264            NodeRequest::CreateWorkspaceFile {
28265                workspace_id: workspace_id.clone(),
28266                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28267            },
28268        )
28269        .await
28270        .unwrap_err();
28271        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28272        let non_directory_parent = process_request_inner(
28273            &shared,
28274            77,
28275            ClientRole::Operator,
28276            NodeRequest::CreateWorkspaceFile {
28277                workspace_id,
28278                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28279            },
28280        )
28281        .await
28282        .unwrap_err();
28283        assert_eq!(
28284            non_directory_parent.code,
28285            NodeFailureCode::RepositoryParentNotDirectory,
28286        );
28287        std::fs::remove_dir_all(root).unwrap();
28288    }
28289
28290    #[tokio::test]
28291    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28292        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28293        std::fs::create_dir_all(root.join("src")).unwrap();
28294        #[cfg(windows)]
28295        let root = std::fs::canonicalize(root).unwrap();
28296        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28297        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28298        let barrier = Arc::new(std::sync::Barrier::new(2));
28299        let done = Arc::new(AtomicBool::new(false));
28300        let task_commit_state = Arc::clone(&commit_state);
28301        let task_barrier = Arc::clone(&barrier);
28302        let task_done = Arc::clone(&done);
28303        let task_root = root.clone();
28304        let task = tokio::task::spawn_blocking(move || {
28305            task_barrier.wait();
28306            let result = create_workspace_file_on_disk(
28307                &task_root,
28308                &path,
28309                &task_commit_state,
28310            );
28311            task_done.store(true, Ordering::Release);
28312            result
28313        });
28314
28315        let error = settle_workspace_entry_create(
28316            task,
28317            Arc::clone(&commit_state),
28318            Duration::from_millis(10),
28319            "fixture deadline",
28320            "fixture task failed",
28321        )
28322        .await
28323        .unwrap_err();
28324        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28325        assert_eq!(
28326            commit_state.load(Ordering::Acquire),
28327            WORKSPACE_ENTRY_CREATE_CANCELED,
28328        );
28329
28330        barrier.wait();
28331        timeout(Duration::from_secs(1), async {
28332            while !done.load(Ordering::Acquire) {
28333                tokio::task::yield_now().await;
28334            }
28335        })
28336        .await
28337        .expect("canceled blocking create did not settle");
28338        assert!(!root.join("src/late.rs").exists());
28339        std::fs::remove_dir_all(root).unwrap();
28340    }
28341}
28342
28343#[cfg(test)]
28344mod provisional_spawn_tests {
28345    use super::*;
28346    use std::sync::Arc;
28347
28348    fn shared() -> NodeShared {
28349        let catalog = active_registry().unwrap();
28350        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28351        let workspace = WorkspaceConfig::new(
28352            WorkspaceId::new("primary").unwrap(),
28353            std::env::current_dir().unwrap(),
28354        )
28355        .unwrap()
28356        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28357        NodeShared::new(
28358            handle,
28359            "fixture-token".to_owned(),
28360            NodeId::new("node-provisional-test").unwrap(),
28361            vec![workspace],
28362            vec![AgentId::new("claude").unwrap()],
28363        )
28364    }
28365
28366    /// Provisional draft map: sessionize by instance id only; 50 sequential
28367    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28368    #[test]
28369    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28370        let shared = shared();
28371        let ws = WorkspaceId::new("primary").unwrap();
28372        let mut ids = Vec::with_capacity(50);
28373        for i in 1u64..=50 {
28374            let id = AgentInstanceId(i);
28375            ids.push(id);
28376            let address = SessionAddress {
28377                workspace_id: ws.clone(),
28378                session: SessionKey {
28379                    instance_id: id,
28380                    generation: SessionGeneration(1),
28381                },
28382            };
28383            shared.mark_spawn_provisional(address);
28384        }
28385        assert_eq!(
28386            shared
28387                .provisional_spawns
28388                .lock()
28389                .unwrap_or_else(|p| p.into_inner())
28390                .len(),
28391            50
28392        );
28393        for id in ids.iter().step_by(2) {
28394            shared.sessionize_spawn(*id);
28395        }
28396        assert_eq!(
28397            shared
28398                .provisional_spawns
28399                .lock()
28400                .unwrap_or_else(|p| p.into_inner())
28401                .len(),
28402            25
28403        );
28404        assert!(shared.take_due_provisional_spawns().is_empty());
28405        {
28406            let mut map = shared
28407                .provisional_spawns
28408                .lock()
28409                .unwrap_or_else(|p| p.into_inner());
28410            let past = Instant::now() - Duration::from_secs(91);
28411            for (_, (_, at)) in map.iter_mut() {
28412                *at = past;
28413            }
28414        }
28415        let due = shared.take_due_provisional_spawns();
28416        assert_eq!(due.len(), 25);
28417        let due_ids: std::collections::BTreeSet<_> =
28418            due.iter().map(|a| a.session.instance_id.0).collect();
28419        let expected: std::collections::BTreeSet<_> =
28420            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28421        assert_eq!(due_ids, expected);
28422        assert!(shared
28423            .provisional_spawns
28424            .lock()
28425            .unwrap_or_else(|p| p.into_inner())
28426            .is_empty());
28427    }
28428
28429    /// Twenty parallel provisional marks must each get their own instance key;
28430    /// killing/sessionizing one id must not touch the others.
28431    #[test]
28432    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28433        let shared = Arc::new(shared());
28434        let ws = WorkspaceId::new("primary").unwrap();
28435        let mut handles = Vec::new();
28436        for i in 1u64..=20 {
28437            let shared = Arc::clone(&shared);
28438            let ws = ws.clone();
28439            handles.push(std::thread::spawn(move || {
28440                let id = AgentInstanceId(i);
28441                let address = SessionAddress {
28442                    workspace_id: ws,
28443                    session: SessionKey {
28444                        instance_id: id,
28445                        generation: SessionGeneration(1),
28446                    },
28447                };
28448                shared.mark_spawn_provisional(address);
28449                id
28450            }));
28451        }
28452        let mut ids = Vec::new();
28453        for h in handles {
28454            ids.push(h.join().unwrap());
28455        }
28456        ids.sort_by_key(|id| id.0);
28457        assert_eq!(ids.len(), 20);
28458        ids.dedup();
28459        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28460        assert_eq!(
28461            shared
28462                .provisional_spawns
28463                .lock()
28464                .unwrap_or_else(|p| p.into_inner())
28465                .len(),
28466            20
28467        );
28468        shared.sessionize_spawn(AgentInstanceId(7));
28469        let left: Vec<_> = shared
28470            .provisional_spawns
28471            .lock()
28472            .unwrap_or_else(|p| p.into_inner())
28473            .keys()
28474            .map(|id| id.0)
28475            .collect();
28476        assert!(!left.contains(&7));
28477        assert_eq!(left.len(), 19);
28478    }
28479}
28480
28481
28482#[cfg(unix)]
28483extern "C" {
28484    #[link_name = "geteuid"]
28485    fn libc_geteuid() -> u32;
28486}
28487
28488#[cfg(test)]
28489mod call_home_link_tests {
28490    use super::*;
28491
28492    #[test]
28493    fn call_home_backoff_is_capped_and_monotonic() {
28494        let mut last = Duration::ZERO;
28495        for attempt in 1..20 {
28496            let next = call_home_backoff(attempt);
28497            assert!(next >= last);
28498            assert!(next <= Duration::from_secs(8));
28499            last = next;
28500        }
28501        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28502    }
28503
28504    #[tokio::test]
28505    async fn call_home_socket_gets_keepalive() {
28506        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28507        let addr = listener.local_addr().unwrap();
28508        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28509        let client = client.unwrap();
28510        enable_call_home_keepalive(&client).unwrap();
28511        let sock = socket2::SockRef::from(&client);
28512        assert!(sock.keepalive().unwrap());
28513        #[cfg(any(target_os = "linux", target_os = "macos"))]
28514        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28515    }
28516}
28517
28518/// A provider transcript for this session id exists in a known provider home.
28519fn provider_session_exists(provider: &str, id: &str) -> bool {
28520    if provider != "claude" { return false; }
28521    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28522    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28523    let name = format!("{id}.jsonl");
28524    homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28525}
28526
28527/// Append launch args to a prepared overlay (or create an instance overlay for them).
28528fn overlay_with_extra_args(
28529    provider: &AgentId,
28530    mode: SessionMode,
28531    overlay: Option<PreparedNativeLaunchOverlay>,
28532    args: Vec<OsString>,
28533) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28534    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28535    Ok(match (overlay, args) {
28536        (prepared, args) if args.is_empty() => prepared,
28537        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28538        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28539        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28540            provider.clone(),
28541            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28542            Vec::new(),
28543            args,
28544        ).map_err(|_| bad())?)),
28545    })
28546}