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
215/// Model of the spawn in progress (for the m4a auto-nick), if the request named one.
216pub fn spawn_model() -> Option<String> {
217    SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218}
219
220tokio::task_local! {
221    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
222    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
223    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
224    /// Provider session id to resume (node-start restore of journalled sessions).
225    static RESUME_PROVIDER_SESSION: Option<String>;
226    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
227    static SPAWN_ORIGIN: Option<String>;
228    static SPAWN_PARENT: Option<String>;
229}
230
231/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
232fn spawn_selection(
233    options: &gate4agent_node_protocol::SpawnSessionOptions,
234) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
235    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
236    match (&options.model, &options.effort) {
237        (None, None) => Ok(None),
238        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
239        (Some(model), effort) => {
240            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
241            if let Some(e) = effort {
242                sel = sel.with_value("effort", e.clone());
243            }
244            Ok(Some(sel))
245        }
246    }
247}
248
249mod browser_bridge;
250
251use gate4agent_node_core::drive_loop_profile;
252
253#[path = "bundle_delivery.rs"]
254mod bundle_delivery;
255
256#[path = "context_pack_store.rs"]
257mod context_pack_store;
258
259#[cfg(windows)]
260pub use crate::platform::DEFAULT_NODE_ENDPOINT;
261
262const NODE_EVENT_HISTORY_MAX: usize = 4_096;
263const NODE_BROADCAST_CAPACITY: usize = 1_024;
264const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
265/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
266/// terminal frame is a full-screen snapshot; only the latest ever matters),
267/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
268/// two pending sends into one would silently drop content a subscriber can
269/// never recover. This needs headroom for a burst instead.
270const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
271/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
272/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
273/// admission check), and `publish_transient` sends each exactly once as a
274/// single-envelope batch -- so this many slots covers the entire pending
275/// set without ever lagging the one subscriber that matters, the harness's
276/// own connection, the way sharing the durable `event_tx`'s bus could.
277const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
278const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
279const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
280const MAX_PREAUTH_CONNECTIONS: usize = 32;
281const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
282const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
283const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
284const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
285const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
286/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
287/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
288/// not the disconnect reconnect grace (HQ owns that, starting only on a real
289/// client WS close). A provider conversation id is a session; a PTY with no
290/// attached HQ view is still a draft and is reaped here if attach never lands.
291const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
292/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
293/// record retention policy (`NodeServerConfig::session_record_retention`).
294/// A sweep cadence, not a policy value -- the policy itself lives entirely
295/// in the operator-configured age/keep-N flags; this just bounds how stale
296/// the in-memory view of dead `Unavailable` records can get between sweeps
297/// on an otherwise-idle node, mirroring the once-per-startup sweep that
298/// also runs alongside `reconcile_managed_worktrees`.
299const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
300/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
301/// field's own doc for why a spawn waiter needs this at all. Must outlive
302/// the longest a waiter can still be polling for this exact command's
303/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
304/// largest `deadline_ms` any caller may request, and the margin on top
305/// absorbs the waiter's own poll cadence plus clock skew between the drive
306/// loop (writer) and the waiter (reader).
307const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
308/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
309/// backlog of asynchronously-rejected commands nobody ever collects (a
310/// waiter that stops polling, or a burst of always-rejected commands from a
311/// misbehaving caller): once at capacity, the oldest entry is evicted to
312/// make room for the newest, mirroring the eviction rule already used by
313/// `spawn_idempotency`.
314const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
315/// How long `wait_until_removed` waits between two `Remove` re-dispatches
316/// for the same session. The presence poll runs far faster (2ms); this
317/// only paces the sending, so an accepted `Remove` still in flight is not
318/// piled on by a dozen more that land on an already-dropped instance.
319const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
320const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
321const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
322const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
323const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
324const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
325const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
326const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
327const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
328const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
329const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
331const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
332/// Default total wall-clock budget for `inspect_workspace`'s walk+git
333/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
334const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
335/// The harness relay enforces its own flat response deadline
336/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
337/// 12s) above this node-side budget. The two constants live in different
338/// crates with no shared definition, so clamp any override here comfortably
339/// below that external ceiling — a misconfigured value above it would
340/// reopen the exact deadline race this budget exists to close.
341const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
342/// Default cap on directory entries visited (not just returned) by one
343/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
344const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
345const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
346const GIT_STATUS_MAX_ENTRIES: usize = 128;
347const GIT_COMMIT_MAX_ENTRIES: usize = 12;
348const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
349const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
350const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
351const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
352const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
353const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
354const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
355const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
356const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
357const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
358    "provider-session-identity-allocation-failed";
359const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
360const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
361const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
362const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
363const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
364const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
365const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
366    "durable-state-path-semantics-unsupported";
367
368fn opaque_windows_path(value: String) -> OpaqueHostPath {
369    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
370}
371
372fn agent_progress_from_provider_snapshot(
373    address: SessionAddress,
374    provider: &ProviderSnapshot,
375) -> Option<SessionAgentProgress> {
376    let mut truncated = false;
377    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
378    let mut active_tool_labels = Vec::with_capacity(
379        provider
380            .active_tools
381            .len()
382            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
383    );
384    for tool in &provider.active_tools {
385        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
386            truncated = true;
387            break;
388        }
389        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
390            active_tool_labels.push(label);
391        }
392    }
393    let attention = provider
394        .interactions
395        .iter()
396        .find(|interaction| {
397            matches!(
398                interaction.status,
399                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
400            )
401        })
402        .map(|interaction| AgentProgressAttentionV1 {
403            kind: match interaction.interaction_kind {
404                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
405                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
406            },
407            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
408        });
409    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
410    let usage = (provider.completed_turns > 0
411        || provider.usage.input_tokens > 0
412        || provider.usage.output_tokens > 0
413        || provider.usage.cache_read_tokens > 0
414        || provider.usage.cache_write_tokens > 0
415        || provider.usage.reasoning_tokens > 0)
416        .then_some(AgentProgressUsageV1 {
417            input_tokens: provider.usage.input_tokens,
418            output_tokens: provider.usage.output_tokens,
419            cache_read_tokens: provider.usage.cache_read_tokens,
420            cache_write_tokens: provider.usage.cache_write_tokens,
421            reasoning_tokens: provider.usage.reasoning_tokens,
422        });
423    let progress = AgentProgressV1 {
424        provider_sequence: provider.sequence,
425        activity: provider.activity,
426        completed_turns: provider.completed_turns,
427        usage,
428        current: AgentProgressCurrentV1::from(provider.activity),
429        active_tool_labels,
430        active_tool_count,
431        attention,
432        subagent_count,
433        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
434        gap_count: provider.gap_count,
435        stale: provider.stale,
436        truncated,
437    };
438    let entry = SessionAgentProgress { address, progress };
439    serde_json::to_vec(&entry)
440        .ok()
441        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
442        .map(|_| entry)
443}
444
445fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
446    match u32::try_from(count) {
447        Ok(count) => count,
448        Err(_) => {
449            *truncated = true;
450            u32::MAX
451        }
452    }
453}
454
455fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
456    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
457        return None;
458    }
459    Some(match event {
460        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
461        ProviderEvent::SessionIdentityObserved { .. } => {
462            AgentProgressEventKindV1::SessionIdentityObserved
463        }
464        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
465        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
466        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
467        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
468        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
469        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
470        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
471        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
472        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
473        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
474        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
475        ProviderEvent::InteractionRequested { .. } => {
476            AgentProgressEventKindV1::InteractionRequested
477        }
478        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
479        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
480        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
481        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
482        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
483        ProviderEvent::UnrecognizedNotification { .. } => {
484            AgentProgressEventKindV1::UnrecognizedNotification
485        }
486        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
487        // ACP session/update coverage beyond text/tool/turn streaming
488        // (`plan`, `available_commands_update`, `current_mode_update`,
489        // `session_info_update`, `usage_update`, `config_option_update`,
490        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
491        // wire enum without a `#[serde(other)]` fallback; minting new
492        // variants for it is a deliberate wire-contract decision for
493        // whoever owns this protocol, not a side effect of parsing more of
494        // ACP's own wire -- until that decision is made, these fold into
495        // the same "we saw something, no dedicated kind for it yet" bucket
496        // as `UnrecognizedNotification`.
497        ProviderEvent::UserMessage { .. }
498        | ProviderEvent::Plan { .. }
499        | ProviderEvent::AvailableCommandsUpdated { .. }
500        | ProviderEvent::ModeChanged { .. }
501        | ProviderEvent::SessionInfoUpdated { .. }
502        | ProviderEvent::UsageUpdated { .. }
503        | ProviderEvent::ConfigOptionsUpdated { .. } => {
504            AgentProgressEventKindV1::UnrecognizedNotification
505        }
506    })
507}
508
509/// Maps this harness's own `HostDecisionAuthority` onto the wider
510/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
511/// chunk carries -- the four authorities a `Denied` decision is ever made
512/// under, one-for-one. The remaining `BlockAuthorityV1` variants
513/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
514/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
515/// block the PROVIDER side of the wire reported -- this function never
516/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
517/// them; the provider-side blocks below mint those directly from typed fields.
518/// Whether a provider source is a live session transport -- the only kind
519/// whose events reach the agent stream. History, resume, session-option and
520/// capability-probe sources are not sessions and stream nothing.
521fn provider_family_is_live(family: AdapterFamily) -> bool {
522    match family {
523        AdapterFamily::PtySemantic
524        | AdapterFamily::Hook
525        | AdapterFamily::ManagedHook
526        | AdapterFamily::Pipe
527        | AdapterFamily::OneShot
528        | AdapterFamily::Acp => true,
529        AdapterFamily::History
530        | AdapterFamily::Resume
531        | AdapterFamily::SessionOptions
532        | AdapterFamily::CapabilityProbe => false,
533    }
534}
535
536fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
537    match by {
538        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
539        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
540        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
541        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
542    }
543}
544
545/// The `Blocked::reason` for a `Denied` decision this build's own
546/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
547/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
548/// request_permission` request resolved by `AcpSession::
549/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
550/// neither of which computes free text (an operator answers approve/reject,
551/// never a comment; an unattended deadline asks nobody anything). `reason`
552/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
553/// -- a categorical fallback naming the authority is the
554/// honest minimum, never a placeholder standing in for detail nobody
555/// reported.
556fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
557    match authority {
558        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
559        BlockAuthorityV1::HostPolicy => "denied by host policy",
560        BlockAuthorityV1::HostDeadline => {
561            "denied by host policy after the operator deadline expired"
562        }
563        BlockAuthorityV1::Operator => "declined by the operator",
564        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
565        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
566        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
567        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
568        BlockAuthorityV1::UserRejected => "rejected by the user",
569        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
570        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
571    }
572}
573
574/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
575/// for a `HostRequestObserved` `Denied` decision.
576fn host_request_denied_block(
577    method: &str,
578    by: ProviderHostDecisionAuthority,
579    reason: Option<&str>,
580) -> (String, BlockAuthorityV1, String) {
581    let authority = block_authority(by);
582    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
583    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
584    // rather than truncating -- this cut happens here, at the producer, before
585    // the validator ever sees the text.
586    let (reason_text, _) =
587        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
588    (tool_class_label(method), authority, reason_text)
589}
590
591// ---------------------------------------------------------------------------
592// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
593// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
594// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
595// prompt` RPC error whose vendor code names a quota/limit/rate
596// exhaustion). See
597// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
598// §3 slice K2.
599// ---------------------------------------------------------------------------
600
601/// Classifies ACP's `nonExecutionKind` into the typed authority a block
602/// this build mints under, or `None` when the tool truly did not run for a
603/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
604/// TURN stopped (session closed, the operator cancelled), not that a
605/// policy/classifier/rule refused the call, so those (and any unrecognized
606/// future value) stay a plain `ToolCompleted { success: false }` only, per
607/// this plan's own rule that a tool failure is never inflated into a block
608/// this build cannot actually attribute.
609fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
610    match kind? {
611        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
612        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
613        _ => None,
614    }
615}
616
617/// Splits a provider's own denial sentence into `reason`/`help`, when it
618/// matches the fixed shape Claude Code's classifier/permission-rule denial
619/// uses -- verified live, the owner's sample fixture
620/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
621/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
622/// `authority` is NEVER derived from this text (see this module's own rule
623/// that authority comes only from a typed field) -- only `reason`/`help`
624/// are. Falls back to the full text as `reason` with no `help` when the
625/// shape does not match (a provider whose text does not carry this exact
626/// wording) -- the honest minimum: the tool truly did not run, so SOME
627/// reason is always reported, never fabricated detail beyond what arrived.
628fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
629    let trimmed = text.trim();
630    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
631        return (trimmed.to_owned(), None);
632    };
633    let tail = after_reason.trim_start();
634    let Some((reason, help)) = tail.split_once(". ") else {
635        // "Reason:" present but no ". " to split help off -- the whole
636        // tail is the reason, e.g. "Reason: Blocked by classifier." with
637        // no help text following on some providers/versions.
638        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
639        return (reason.to_owned(), None);
640    };
641    let help = help.trim();
642    if help.is_empty() {
643        (reason.trim().to_owned(), None)
644    } else {
645        (reason.trim().to_owned(), Some(help.to_owned()))
646    }
647}
648
649/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
650/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
651/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
652/// name is never threaded through to its completion). Reads the class off
653/// the guidance tail's own "add a <Class> permission rule" sentence when
654/// present (the shape the owner's sample fixture uses) -- text matching
655/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
656/// this module's own rule). Falls back to the same generic `"Tool"` class
657/// `ToolCompleted`'s own observation already uses when nothing names one.
658fn tool_class_from_denial_help(help: Option<&str>) -> String {
659    help.and_then(|text| text.split("add a ").nth(1))
660        .and_then(|tail| tail.split(' ').next())
661        .filter(|word| !word.is_empty())
662        .map(str::to_owned)
663        .unwrap_or_else(|| "Tool".to_owned())
664}
665
666/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
667/// message) names a quota/rate-limit exhaustion -- Codex's
668/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
669/// contains "limit", the word this build actually keys on; "quota"/"rate"
670/// are kept for whichever OTHER vendor's own code names one of those
671/// instead, never confirmed live, but the honest generalization of the
672/// one confirmed sample.
673fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
674    fn names_quota(s: &str) -> bool {
675        let lower = s.to_ascii_lowercase();
676        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
677    }
678    vendor_code.is_some_and(names_quota) || names_quota(message)
679}
680
681/// The `Blocked` chunk this build mints when a turn stopped via a
682/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
683/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
684/// vendor code (or message) names a quota/limit/rate exhaustion
685/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
686/// `reason_kind` = the vendor code). `None` for every other stop --
687/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
688/// `Other`, or an RPC error whose vendor code/message names none of the
689/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
690/// those, never inflated into a block this build cannot actually
691/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
692/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
693/// honest minimum names the fact that happened, never invented detail; a
694/// later slice that threads the turn's last agent text chunk through can
695/// replace it without changing this function's contract.
696fn provider_session_end_block(
697    stop_reason: &ProviderStopReason,
698) -> Option<(BlockAuthorityV1, String, String)> {
699    match stop_reason {
700        ProviderStopReason::Refusal => Some((
701            BlockAuthorityV1::ProviderRefusal,
702            "refusal".to_owned(),
703            "the provider ended the turn with stopReason: refusal".to_owned(),
704        )),
705        ProviderStopReason::ProviderError { message, vendor_code, .. }
706            if provider_error_is_quota(vendor_code.as_deref(), message) =>
707        {
708            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
709            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
710        }
711        _ => None,
712    }
713}
714
715/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
716/// `agent-stream-events-v1` channel -- see
717/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
718/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
719/// event it derives from, so a client joining the two sees one id and one
720/// ordering:
721///
722/// - `source_sequence` on the chunk is the `source_sequence` the provider
723///   event itself carries.
724/// - `InteractionPrompt::correlation_id` is minted by
725///   `correlation::interaction_correlation` with the
726///   `(instance_id, generation, provider_sequence)` triple -- never the
727///   provider's own `request_id`, whose opacity on the wire is pinned by a
728///   dedicated test.
729///
730/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
731/// (nothing else carries stream content), for a source that is not a live
732/// session transport, and for the `ProviderEvent` variants below with no
733/// declared chunk kind to carry them, or whose declared chunk kind this build
734/// has no source for yet:
735///
736/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
737///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
738///   yet, the same gap the plan names explicitly.
739/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
740///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
741///   as "what the agent is saying", so mapping the operator's words onto
742///   `Text` would misrepresent whose content it is. No dedicated
743///   user-message kind is declared, so this stays unproduced rather than
744///   mislabeled.
745///
746/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
747/// decision returns `None` (nothing for the operator to react to), a `Denied`
748/// decision returns `Some(Blocked { .. })`.
749///
750/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
751/// below (K2): each returns `None` unless
752/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
753/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
754/// RPC error naming a quota).
755fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
756    let ControlEventKind::ProviderEvent {
757        sequence: provider_sequence,
758        source,
759        source_sequence,
760        event: provider_event,
761    } = &event.event
762    else {
763        return None;
764    };
765    if !provider_family_is_live(source.family) {
766        return None;
767    }
768    let kind = match provider_event {
769        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
770            text: text.clone(),
771            is_delta: *is_delta,
772        },
773        ProviderEvent::Thinking { text } => {
774            AgentStreamChunkKindV1::Thinking { text: text.clone() }
775        }
776        ProviderEvent::InteractionRequested {
777            interaction_kind,
778            tool_name,
779            title,
780            prompt,
781            options,
782            ..
783        } => AgentStreamChunkKindV1::InteractionPrompt {
784            correlation_id: interaction_correlation(
785                event.instance_id,
786                event.generation,
787                *provider_sequence,
788            ),
789            interaction_kind: *interaction_kind,
790            tool_name: tool_name.clone(),
791            // `title`/`options` are carried straight through from
792            // `ProviderEvent::InteractionRequested`, as the agent itself
793            // gave them on `session/request_permission`'s `PermissionTool
794            // Call.title`/`options` (see `gate4agent-shell-native`'s
795            // `provider_event`, the only ACP-transport producer). A PTY- or
796            // hook-sourced interaction carries `title: None` and
797            // `options: []` from its own producer because that source has
798            // none to offer, never because this mapping dropped one.
799            title: title.clone(),
800            prompt: prompt.clone(),
801            options: options
802                .iter()
803                .map(|option| AgentStreamInteractionOptionV1 {
804                    option_id: option.option_id.clone(),
805                    name: option.name.clone(),
806                    kind: option.kind.clone(),
807                })
808                .collect(),
809        },
810        ProviderEvent::ConfigOptionsUpdated { options } => {
811            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
812        }
813        // `available` is the mode catalogue `AcpSession::available_modes()`
814        // read back at `session/new` and threaded through by
815        // `gate4agent-shell-native`'s `provider_event` (see that crate's
816        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
817        // agent's own honest "no modes announced", never "not read": ACP
818        // orders `session/new` before any `session/update`, and the
819        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
820        // exist before the same session object's catalogue is already
821        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
822        // own doc comment for the ordering argument in full).
823        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
824            current: Some(mode_id.clone()),
825            available: available
826                .iter()
827                .map(|mode| AgentStreamNamedIdV1 {
828                    id: mode.id.clone(),
829                    name: mode.name.clone(),
830                    description: mode.description.clone(),
831                })
832                .collect(),
833        },
834        // A denied host request is a block -- see this function's own doc
835        // comment for why `HostRequestObserved` is not in the exclusion list
836        // below.
837        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
838            let ProviderHostRequestDecision::Denied { by } = decision else {
839                return None;
840            };
841            let (tool_class, authority, reason_text) =
842                host_request_denied_block(method, *by, reason.as_deref());
843            AgentStreamChunkKindV1::Blocked {
844                correlation_id: None,
845                tool_class,
846                authority,
847                reason_kind: None,
848                reason: reason_text,
849                help: None,
850            }
851        }
852        // Same relationship as `HostRequestObserved` above, driven by ACP's
853        // own `_meta.nonExecutionKind` instead of the host's
854        // `HostRequestDecision`.
855        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
856            let (authority, reason_kind) =
857                provider_tool_block_authority(non_execution_kind.as_deref())?;
858            let (reason, help) = split_provider_denial_text(output);
859            let (reason, _) =
860                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
861            let help = help.map(|help| {
862                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
863            });
864            AgentStreamChunkKindV1::Blocked {
865                correlation_id: Some(tool_correlation(event, source, id)),
866                tool_class: tool_class_from_denial_help(help.as_deref()),
867                authority,
868                reason_kind: Some(reason_kind.to_owned()),
869                reason,
870                help,
871            }
872        }
873        // Same relationship, driven by `stopReason: "refusal"` or a
874        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
875        // instead of a tool-level fact.
876        ProviderEvent::SessionEnded { stop_reason, .. } => {
877            let (authority, reason_kind, reason) =
878                stop_reason.as_ref().and_then(provider_session_end_block)?;
879            let (reason, _) =
880                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
881            AgentStreamChunkKindV1::Blocked {
882                correlation_id: None,
883                tool_class: "Session".to_owned(),
884                authority,
885                reason_kind: Some(reason_kind),
886                reason,
887                help: None,
888            }
889        }
890        ProviderEvent::SessionStarted { .. }
891        | ProviderEvent::SessionIdentityObserved { .. }
892        | ProviderEvent::TurnStarted { .. }
893        | ProviderEvent::WorkingObserved
894        | ProviderEvent::ToolStarted { .. }
895        | ProviderEvent::TurnCompleted { .. }
896        | ProviderEvent::ContextWindowUsage { .. }
897        | ProviderEvent::TurnInterrupted
898        | ProviderEvent::Error { .. }
899        | ProviderEvent::Ready
900        | ProviderEvent::InteractionResolved { .. }
901        | ProviderEvent::SubagentStarted { .. }
902        | ProviderEvent::SubagentStopped { .. }
903        | ProviderEvent::RateLimited { .. }
904        | ProviderEvent::UnrecognizedNotification { .. }
905        | ProviderEvent::UserMessage { .. }
906        | ProviderEvent::Plan { .. }
907        | ProviderEvent::AvailableCommandsUpdated { .. }
908        | ProviderEvent::SessionInfoUpdated { .. }
909        | ProviderEvent::UsageUpdated { .. } => return None,
910    };
911    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
912}
913
914async fn run_native_session_catalog_operation<T, E, F>(
915    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
916    operation: &'static str,
917    execute: F,
918) -> Result<T, NodeFailure>
919where
920    T: Send + 'static,
921    E: std::fmt::Display + Send + 'static,
922    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
923{
924    let task = tokio::task::spawn_blocking(move || {
925        let mut catalog = match catalog.try_lock() {
926            Ok(catalog) => catalog,
927            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
928            Err(std::sync::TryLockError::WouldBlock) => {
929                return Err(failure(
930                    NodeFailureCode::BackendBusy,
931                    "native session catalog operation is already running",
932                ));
933            }
934        };
935        execute(&mut catalog).map_err(|error| {
936            failure(
937                NodeFailureCode::BackendOperationFailed,
938                &format!("{operation} failed: {error}"),
939            )
940        })
941    });
942    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
943        .await
944        .map_err(|_| {
945            failure(
946                NodeFailureCode::BackendBusy,
947                &format!("{operation} exceeded its bounded deadline"),
948            )
949        })?
950        .map_err(|_| {
951            failure(
952                NodeFailureCode::BackendOperationFailed,
953                &format!("{operation} task failed"),
954            )
955        })?
956}
957
958fn windows_path_text(path: &OpaqueHostPath) -> &str {
959    path.as_utf8()
960        .expect("Windows node only creates UTF-8 opaque host paths")
961}
962
963fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
964    path.as_utf8()
965        .map(str::to_owned)
966        .ok_or_else(|| failure(
967            NodeFailureCode::InvalidRequest,
968            "this node requires UTF-8 host paths",
969        ))
970}
971
972fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
973    GitWorktreeSnapshot {
974        path: opaque_windows_path(worktree.path),
975        head: worktree.head,
976        branch: worktree.branch,
977        is_bare: worktree.is_bare,
978        is_main: worktree.is_main,
979        locked: worktree.locked,
980        lock_reason: worktree.lock_reason,
981        prunable: worktree.prunable,
982        prunable_reason: worktree.prunable_reason,
983        workspace_id: worktree.workspace_id.take(),
984    }
985}
986
987#[derive(Clone, Debug, Eq, PartialEq)]
988pub struct WorkspaceConfig {
989    workspace_id: WorkspaceId,
990    canonical_root: String,
991    worktree_service_mode: WorktreeServiceMode,
992    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
993}
994
995impl WorkspaceConfig {
996    pub fn new(
997        workspace_id: WorkspaceId,
998        root: impl AsRef<Path>,
999    ) -> Result<Self, NodeServerError> {
1000        let root = root.as_ref();
1001        if !root.is_absolute() {
1002            return Err(NodeServerError::InvalidWorkspaceRoot {
1003                workspace_id,
1004                path: root.to_string_lossy().into_owned(),
1005                message: "path is not absolute".to_owned(),
1006            });
1007        }
1008        let canonical = std::fs::canonicalize(root).map_err(|error| {
1009            NodeServerError::InvalidWorkspaceRoot {
1010                workspace_id: workspace_id.clone(),
1011                path: root.to_string_lossy().into_owned(),
1012                message: error.to_string(),
1013            }
1014        })?;
1015        if !canonical.is_dir() {
1016            return Err(NodeServerError::InvalidWorkspaceRoot {
1017                workspace_id,
1018                path: root.to_string_lossy().into_owned(),
1019                message: "path is not a directory".to_owned(),
1020            });
1021        }
1022        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1023            NodeServerError::InvalidWorkspaceRoot {
1024                workspace_id: workspace_id.clone(),
1025                path: path.to_string_lossy().into_owned(),
1026                message: "canonical path is not valid Unicode".to_owned(),
1027            }
1028        })?;
1029        let canonical_root = platform::normalize_canonical_root(canonical_root);
1030        validate_workspace_root(&workspace_id, &canonical_root)?;
1031        Ok(Self {
1032            workspace_id,
1033            canonical_root,
1034            worktree_service_mode: WorktreeServiceMode::Manual,
1035            managed_worktree_profiles: BTreeMap::new(),
1036        })
1037    }
1038
1039    pub fn workspace_id(&self) -> &WorkspaceId {
1040        &self.workspace_id
1041    }
1042
1043    pub fn canonical_root(&self) -> &str {
1044        &self.canonical_root
1045    }
1046
1047    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1048        self.worktree_service_mode = mode;
1049        self
1050    }
1051
1052    pub fn with_managed_worktree_profile(
1053        mut self,
1054        profile: ManagedWorktreeProfile,
1055    ) -> Result<Self, NodeServerError> {
1056        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1057            NodeServerError::InvalidManagedWorktreeProfile {
1058                workspace_id: self.workspace_id.clone(),
1059                profile_id: profile.profile_id().clone(),
1060                message,
1061            }
1062        })?;
1063        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1064            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1065                workspace_id: self.workspace_id,
1066                profile_id: profile.profile_id().clone(),
1067            });
1068        }
1069        if self.managed_worktree_profiles.len()
1070            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1071        {
1072            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1073                workspace_id: self.workspace_id,
1074                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1075            });
1076        }
1077        self.managed_worktree_profiles
1078            .insert(profile.profile_id().clone(), profile);
1079        Ok(self)
1080    }
1081}
1082
1083#[derive(Clone)]
1084pub struct NodeServerConfig {
1085    pub endpoint: String,
1086    api_listen: Option<std::net::SocketAddr>,
1087    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1088    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1089    bridge_listen: Option<std::net::SocketAddr>,
1090    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1091    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1092    bridge_token: Option<String>,
1093    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1094    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1095    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1096    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1097    /// node purely a listener, which is what it has always been.
1098    call_home: Option<std::net::SocketAddr>,
1099    /// When set, call-home authenticates by signing the relay's challenge with this
1100    /// identity (enrolled node) and falls back to the token handshake against a relay
1101    /// that sends no challenge.
1102    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1103    pub node_id: NodeId,
1104    pub workspaces: Vec<WorkspaceConfig>,
1105    access_token: String,
1106    pub runtime: NativeRuntimeConfig,
1107    session_record_retention: SessionRecordRetentionConfig,
1108    state_path: Option<PathBuf>,
1109    spawn_profiles: SpawnProfileRegistry,
1110    session_environment: Option<NodeSessionEnvironmentConfig>,
1111    history: Option<NativeHistoryConfig>,
1112    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1113    /// Station network allowlist catalog (opaque ids). Empty default.
1114    /// Loaded at startup from `--network-allowlist-catalog` / env
1115    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1116    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1117    #[cfg(feature = "fixture")]
1118    fixture_raw_pty_runtime: bool,
1119}
1120
1121#[derive(Clone)]
1122struct NodeSessionEnvironmentConfig {
1123    root: PathBuf,
1124    resolver: Arc<dyn NodeSecretResolver>,
1125}
1126
1127impl NodeServerConfig {
1128    pub fn new(
1129        endpoint: impl Into<String>,
1130        access_token: impl Into<String>,
1131        node_id: NodeId,
1132        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1133    ) -> Result<Self, NodeServerError> {
1134        let endpoint = endpoint.into();
1135        let access_token = access_token.into();
1136        if !platform::validate_endpoint(&endpoint) {
1137            return Err(NodeServerError::InvalidEndpoint);
1138        }
1139        if access_token.is_empty() || access_token.len() > 4_096 {
1140            return Err(NodeServerError::InvalidAccessToken);
1141        }
1142        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1143        if workspaces.is_empty() {
1144            return Err(NodeServerError::NoWorkspaces);
1145        }
1146        let mut workspace_ids = BTreeMap::new();
1147        let mut workspace_roots = BTreeMap::new();
1148        for workspace in &workspaces {
1149            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1150                && workspace.managed_worktree_profiles.is_empty()
1151            {
1152                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1153                    workspace.workspace_id.clone(),
1154                ));
1155            }
1156            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1157                && !workspace.managed_worktree_profiles.is_empty()
1158            {
1159                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1160                    workspace.workspace_id.clone(),
1161                ));
1162            }
1163            if workspace_ids
1164                .insert(workspace.workspace_id.clone(), ())
1165                .is_some()
1166            {
1167                return Err(NodeServerError::DuplicateWorkspaceId(
1168                    workspace.workspace_id.clone(),
1169                ));
1170            }
1171            let root_key = platform::root_identity(&workspace.canonical_root);
1172            if let Some(existing) = workspace_roots.insert(
1173                root_key,
1174                workspace.workspace_id.clone(),
1175            ) {
1176                return Err(NodeServerError::DuplicateWorkspaceRoot {
1177                    first: existing,
1178                    second: workspace.workspace_id.clone(),
1179                    root: workspace.canonical_root.clone(),
1180                });
1181            }
1182        }
1183        Ok(Self {
1184            endpoint,
1185            api_listen: None,
1186            bridge_listen: None,
1187            bridge_token: None,
1188            bridge_underlay: None,
1189            call_home: None,
1190            call_home_identity: None,
1191            node_id,
1192            workspaces,
1193            access_token,
1194            // PTY output is polled by the runtime's effect worker; the upstream
1195            // default (20ms) added ~10-20ms to every keystroke echo.
1196            runtime: NativeRuntimeConfig {
1197                worker_poll_interval_ms: 4,
1198                ..NativeRuntimeConfig::default()
1199            },
1200            session_record_retention: SessionRecordRetentionConfig::default(),
1201            state_path: None,
1202            spawn_profiles: SpawnProfileRegistry::default(),
1203            session_environment: None,
1204            history: None,
1205            harness_mcp_helper: None,
1206            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1207            #[cfg(feature = "fixture")]
1208            fixture_raw_pty_runtime: false,
1209        })
1210    }
1211
1212    pub fn with_api_listen(
1213        mut self,
1214        api_listen: std::net::SocketAddr,
1215    ) -> Result<Self, NodeServerError> {
1216        if !api_listen.ip().is_loopback() {
1217            return Err(NodeServerError::InvalidApiListen(api_listen));
1218        }
1219        self.api_listen = Some(api_listen);
1220        Ok(self)
1221    }
1222
1223    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1224    /// as `with_api_listen`). Does not enable by default — libraries and the
1225    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1226    pub fn with_bridge_listen(
1227        mut self,
1228        bridge_listen: std::net::SocketAddr,
1229    ) -> Result<Self, NodeServerError> {
1230        if !bridge_listen.ip().is_loopback() {
1231            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1232        }
1233        self.bridge_listen = Some(bridge_listen);
1234        Ok(self)
1235    }
1236
1237    /// Optional bridge shared secret, distinct from the C2/node access token.
1238    /// Never log the value. Empty / oversize refused.
1239    pub fn with_bridge_token(
1240        mut self,
1241        bridge_token: impl Into<String>,
1242    ) -> Result<Self, NodeServerError> {
1243        let bridge_token = bridge_token.into();
1244        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1245            return Err(NodeServerError::InvalidBridgeToken);
1246        }
1247        self.bridge_token = Some(bridge_token);
1248        Ok(self)
1249    }
1250
1251    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1252    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1253    /// already configured. Transport key is 32 raw bytes (never logged).
1254    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1255    pub fn with_bridge_underlay(
1256        mut self,
1257        bind: std::net::SocketAddr,
1258        transport_key: [u8; 32],
1259        underlay_token: impl Into<String>,
1260    ) -> Result<Self, NodeServerError> {
1261        if self.bridge_listen.is_none() {
1262            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1263        }
1264        if self.bridge_token.is_none() {
1265            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1266        }
1267        let underlay_token = underlay_token.into();
1268        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1269            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1270        }
1271        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1272        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1273            bind,
1274            transport_key,
1275            underlay_token,
1276        });
1277        Ok(self)
1278    }
1279
1280    /// Also dial `relay` and serve this wire over the connection, in
1281    /// addition to accepting on the local endpoint.
1282    ///
1283    /// Who opens the socket and who is the protocol's server are separate
1284    /// questions, and this is the node's half of separating them. The node
1285    /// stays the server either way -- it answers `NodeRequest`s and emits
1286    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1287    /// that changes is that a node with no address anyone can reach makes
1288    /// the connection itself, and names itself once it arrives (see
1289    /// `gate4agent_node_wire::write_call_home_announce`).
1290    ///
1291    /// Loopback only, and that is not an oversight to be flagged and
1292    /// worked around. This wire carries terminal contents, keystrokes and
1293    /// file bytes as plaintext JSON; its mutual challenge-response
1294    /// authenticates both ends and encrypts nothing. Every listener in
1295    /// this stack is loopback-guarded for that reason, and a dialler must
1296    /// hold the same line -- a node calling a public relay would put the
1297    /// whole control plane on the wire in the clear. Reaching a relay on
1298    /// another host is a transport question, and it stays unanswered here
1299    /// on purpose: this change is about direction, not distance.
1300    pub fn with_call_home(
1301        mut self,
1302        relay: std::net::SocketAddr,
1303    ) -> Result<Self, NodeServerError> {
1304        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1305        // and port 0 refused — plaintext wire must not dial every interface.
1306        if relay.port() == 0 || relay.ip().is_unspecified() {
1307            return Err(NodeServerError::InvalidCallHome(relay));
1308        }
1309        self.call_home = Some(relay);
1310        Ok(self)
1311    }
1312
1313    /// Authenticate call-home with a node-held identity instead of a standing token.
1314    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1315        self.call_home_identity = Some(identity);
1316        self
1317    }
1318
1319    pub fn with_state_path(
1320        mut self,
1321        state_path: impl Into<PathBuf>,
1322    ) -> Result<Self, NodeServerError> {
1323        let state_path = state_path.into();
1324        if !state_path.is_absolute() || state_path.file_name().is_none() {
1325            return Err(NodeServerError::InvalidStatePath(
1326                state_path.to_string_lossy().into_owned(),
1327            ));
1328        }
1329        self.state_path = Some(state_path);
1330        Ok(self)
1331    }
1332
1333    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1334        self.spawn_profiles = spawn_profiles;
1335        self
1336    }
1337
1338    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1339    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1340    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1341    /// when this is never called -- a node never deletes a durable record
1342    /// until an operator opts in with a real value.
1343    pub fn with_session_record_retention(
1344        mut self,
1345        retention: SessionRecordRetentionConfig,
1346    ) -> Self {
1347        self.session_record_retention = retention;
1348        self
1349    }
1350
1351    pub fn with_session_environment_materialization(
1352        mut self,
1353        root: impl Into<PathBuf>,
1354        resolver: Arc<dyn NodeSecretResolver>,
1355    ) -> Result<Self, NodeServerError> {
1356        let root = root.into();
1357        if !root.is_absolute() {
1358            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1359        }
1360        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1361        Ok(self)
1362    }
1363
1364    /// Enables bounded provider-history discovery and loading from explicit
1365    /// host-owned roots. Ambient vendor homes are never inferred here.
1366    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1367        self.history = Some(history);
1368        self
1369    }
1370
1371    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1372    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1373    /// Both platforms review the helper the same way (absolute regular file +
1374    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1375    /// gate after Unix listener/peer-cred support landed.
1376    pub fn with_harness_mcp_helper(
1377        mut self,
1378        helper_program: impl Into<PathBuf>,
1379    ) -> Result<Self, NodeServerError> {
1380        self.harness_mcp_helper = Some(
1381            ReviewedHarnessMcpProgram::review(helper_program.into())
1382                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1383        );
1384        Ok(self)
1385    }
1386
1387    /// Install an in-memory station network allowlist catalog (opaque ids +
1388    /// optional node-local permits / provider-native mapping). Empty remains
1389    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1390    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1391    pub fn with_network_allowlist_catalog(
1392        mut self,
1393        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1394    ) -> Result<Self, NodeServerError> {
1395        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1396            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1397                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1398            });
1399        }
1400        self.network_allowlist_catalog = catalog;
1401        Ok(self)
1402    }
1403
1404    /// Load station network allowlist catalog from an absolute regular file
1405    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1406    /// that want env fallback use `resolve_network_allowlist_catalog`.
1407    pub fn with_network_allowlist_catalog_file(
1408        self,
1409        path: impl AsRef<std::path::Path>,
1410    ) -> Result<Self, NodeServerError> {
1411        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1412            .map_err(NodeServerError::from)?;
1413        self.with_network_allowlist_catalog(catalog)
1414    }
1415}
1416
1417pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1418    platform::default_state_path(node_id.as_str())
1419        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1420}
1421
1422pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1423    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1424}
1425
1426fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1427    let parent = state_path.parent()?;
1428    let mut name = state_path.file_name()?.to_os_string();
1429    name.push(".delivery-store");
1430    Some(parent.join(name))
1431}
1432
1433fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1434    let parent = state_path.parent()?;
1435    let mut name = state_path.file_name()?.to_os_string();
1436    name.push(".context-pack-store");
1437    Some(parent.join(name))
1438}
1439
1440pub struct NodeServer {
1441    config: NodeServerConfig,
1442    runtime: NativeRuntime,
1443    shared: Arc<NodeShared>,
1444    events: EventSubscription,
1445    state_path_lock: Option<session_registry::StatePathLock>,
1446}
1447
1448#[cfg(feature = "fixture")]
1449#[derive(Clone, Default)]
1450pub struct SpawnManagedWorktreeV2FailureProbe {
1451    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1452}
1453
1454#[cfg(feature = "fixture")]
1455impl SpawnManagedWorktreeV2FailureProbe {
1456    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1457        *self
1458            .latest_code
1459            .lock()
1460            .unwrap_or_else(|poisoned| poisoned.into_inner())
1461    }
1462
1463    fn record(&self, code: Option<NodeFailureCode>) {
1464        *self
1465            .latest_code
1466            .lock()
1467            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1468    }
1469}
1470
1471#[cfg(all(feature = "fixture", windows))]
1472struct IsolatedCodexHomeFixtureEnvironment;
1473
1474#[cfg(all(feature = "fixture", windows))]
1475impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1476    for IsolatedCodexHomeFixtureEnvironment
1477{
1478    fn resolve_child_environment(
1479        &self,
1480    ) -> Result<
1481        Vec<gate4agent_catalog::EnvMutation>,
1482        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1483    > {
1484        Ok(vec![gate4agent_catalog::EnvMutation {
1485            key: OsString::from("USERPROFILE"),
1486            value: None,
1487        }])
1488    }
1489}
1490
1491impl NodeServer {
1492    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1493    /// must present.
1494    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1495    #[cfg(unix)]
1496    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1497        self.shared.signer_proxy.set(proxy).is_ok()
1498    }
1499
1500    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1501        self.shared.m4a_accounts.as_ref()?;
1502        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1503    }
1504
1505    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1506        let catalog = active_registry()?;
1507        Self::new_with_registry(config, catalog)
1508    }
1509
1510    #[cfg(feature = "fixture")]
1511    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1512        let mut spec = gate4agent_testkit::interactive_agent_spec();
1513        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1514        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1515        Self::new_fixture_with_spec(config, spec)
1516    }
1517
1518    #[cfg(feature = "fixture")]
1519    pub fn new_clean_exit_fixture(
1520        config: NodeServerConfig,
1521        fixture_root: PathBuf,
1522        started_marker: PathBuf,
1523        release_signal: PathBuf,
1524    ) -> Result<Self, NodeServerError> {
1525        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1526            &fixture_root,
1527            &started_marker,
1528            &release_signal,
1529        )
1530        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1531        spec.id = AgentId::new("claude")
1532            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1533        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1534        Self::new_fixture_with_spec(config, spec)
1535    }
1536
1537    /// A `claude` source whose session establishes a verified provider
1538    /// identity via an early `SessionStart` Hook post (so it is eligible for
1539    /// auto-export-at-exit and its native history is independently
1540    /// loadable), waits for `release_signal`, then exits cleanly — paired
1541    /// on the same Node with a plain `codex` target that never validates
1542    /// anything about what it receives. Built for A2 durable ContextPack
1543    /// E2E coverage: the test itself inspects materialized files and
1544    /// managed-session-record fields directly, rather than having the
1545    /// target script self-validate.
1546    #[cfg(all(feature = "fixture", windows))]
1547    pub fn new_durable_context_pack_clean_exit_fixture(
1548        config: NodeServerConfig,
1549        fixture_root: PathBuf,
1550        started_marker: PathBuf,
1551        release_signal: PathBuf,
1552        session_id: &str,
1553    ) -> Result<Self, NodeServerError> {
1554        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1555            &fixture_root,
1556            &started_marker,
1557            &release_signal,
1558            session_id,
1559        )
1560        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1561        let source_id = AgentId::new("claude")
1562            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1563        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1564            NodeServerError::Registry(
1565                "durable context pack clean-exit fixture source adapter is unavailable"
1566                    .to_owned(),
1567            )
1568        })?;
1569        source_spec.capabilities.adapters.history =
1570            source_provider.capabilities.adapters.history.clone();
1571        source_spec.id = source_id;
1572        source_spec.display_name =
1573            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1574        // Both specs launch via `powershell.exe`; the registry's ambiguity
1575        // check keys on `detection.command` (+ aliases), not `launch.program`
1576        // -- give each a distinct detection identity, same precedent as
1577        // `context_pack_fixture_catalog`'s per-provider
1578        // `gate4agent-{provider}-context-fixture.cmd` commands.
1579        source_spec.detection.command =
1580            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1581        source_spec.detection.aliases.clear();
1582
1583        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1584        target_spec.id = AgentId::new("codex")
1585            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586        target_spec.display_name =
1587            "Controlled Codex durable context-pack target fixture".to_owned();
1588        target_spec.detection.command =
1589            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1590        target_spec.detection.aliases.clear();
1591
1592        let catalog = AgentRegistry::new([source_spec, target_spec])
1593            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1594        let mut server = Self::new_with_registry(config, catalog)?;
1595        Arc::get_mut(&mut server.shared)
1596            .expect("fixture Node shared state must remain uniquely owned before run")
1597            .fixture_semantic_hook_policy = true;
1598        Ok(server)
1599    }
1600
1601    #[cfg(feature = "fixture")]
1602    pub fn new_monitoring_hook_fixture(
1603        config: NodeServerConfig,
1604    ) -> Result<Self, NodeServerError> {
1605        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1606        spec.id = AgentId::new("claude")
1607            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1608        let mut server = Self::new_fixture_with_spec(config, spec)?;
1609        Arc::get_mut(&mut server.shared)
1610            .expect("fixture Node shared state must remain uniquely owned before run")
1611            .fixture_semantic_hook_policy = true;
1612        Ok(server)
1613    }
1614
1615    #[cfg(all(feature = "fixture", windows))]
1616    pub fn new_provider_bundle_argv_fixture(
1617        config: NodeServerConfig,
1618        agent_id: AgentId,
1619        proof_path: PathBuf,
1620    ) -> Result<Self, NodeServerError> {
1621        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1622        Self::new_fixture_with_spec(config, spec)
1623    }
1624
1625    #[cfg(all(feature = "fixture", windows))]
1626    pub fn new_provider_bundle_argv_hold_fixture(
1627        config: NodeServerConfig,
1628        agent_id: AgentId,
1629        proof_path: PathBuf,
1630        release_signal: PathBuf,
1631    ) -> Result<Self, NodeServerError> {
1632        let spec = Self::provider_bundle_argv_fixture_spec(
1633            agent_id,
1634            proof_path,
1635            Some(release_signal),
1636        )?;
1637        Self::new_fixture_with_spec(config, spec)
1638    }
1639
1640    #[cfg(all(feature = "fixture", windows))]
1641    fn provider_bundle_argv_fixture_spec(
1642        agent_id: AgentId,
1643        proof_path: PathBuf,
1644        release_signal: Option<PathBuf>,
1645    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1646        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1647            return Err(NodeServerError::Registry(
1648                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1649            ));
1650        }
1651        let mut spec = gate4agent_testkit::interactive_agent_spec();
1652        spec.id = agent_id.clone();
1653        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1654        let provider = builtin_registry()
1655            .get(&agent_id)
1656            .ok_or_else(|| {
1657                NodeServerError::Registry(
1658                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1659                )
1660            })?;
1661        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1662        if !proof_path.is_absolute()
1663            || !proof_path
1664                .parent()
1665                .is_some_and(|parent| parent.is_dir())
1666        {
1667            return Err(NodeServerError::Registry(
1668                "provider bundle argv proof path requires an existing absolute parent"
1669                    .to_owned(),
1670            ));
1671        }
1672        if let Some(release_signal) = release_signal.as_ref() {
1673            if !release_signal.is_absolute()
1674                || !release_signal
1675                    .parent()
1676                    .is_some_and(|parent| parent.is_dir())
1677            {
1678                return Err(NodeServerError::Registry(
1679                    "provider bundle argv release path requires an existing absolute parent"
1680                        .to_owned(),
1681                ));
1682            }
1683            if release_signal.exists() {
1684                return Err(NodeServerError::Registry(
1685                    "provider bundle argv release path must not exist".to_owned(),
1686                ));
1687            }
1688            let proof_parent = std::fs::canonicalize(
1689                proof_path
1690                    .parent()
1691                    .expect("validated provider bundle proof parent"),
1692            )
1693            .map_err(|_| {
1694                NodeServerError::Registry(
1695                    "provider bundle argv proof parent is unavailable".to_owned(),
1696                )
1697            })?;
1698            let release_parent = std::fs::canonicalize(
1699                release_signal
1700                    .parent()
1701                    .expect("validated provider bundle release parent"),
1702            )
1703            .map_err(|_| {
1704                NodeServerError::Registry(
1705                    "provider bundle argv release parent is unavailable".to_owned(),
1706                )
1707            })?;
1708            if proof_parent != release_parent {
1709                return Err(NodeServerError::Registry(
1710                    "provider bundle argv release path must share the proof parent".to_owned(),
1711                ));
1712            }
1713        }
1714        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1715            NodeServerError::Registry(
1716                "provider bundle argv proof path is not valid Unicode".to_owned(),
1717            )
1718        })?;
1719        let release_signal = release_signal.map(|release_signal| {
1720            release_signal.into_os_string().into_string().map_err(|_| {
1721                NodeServerError::Registry(
1722                    "provider bundle argv release path is not valid Unicode".to_owned(),
1723                )
1724            })
1725        }).transpose()?;
1726        let script = spec
1727            .launch
1728            .fixed_args
1729            .pop()
1730            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1731        let bundle_validation = if agent_id.as_str() == "codex" {
1732            "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') }"
1733        } else if agent_id.as_str() == "claude" {
1734            "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 }"
1735        } else {
1736            "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 }"
1737        };
1738        if let Some(release_signal) = release_signal {
1739            spec.launch.fixed_args.push(format!(
1740                "& {{ 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} }}",
1741            ));
1742            spec.launch.fixed_args.push(proof_path);
1743            spec.launch.fixed_args.push(release_signal);
1744        } else {
1745            spec.launch.fixed_args.push(format!(
1746                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1747            ));
1748            spec.launch.fixed_args.push(proof_path);
1749        }
1750        Ok(spec)
1751    }
1752
1753    #[cfg(all(feature = "fixture", windows))]
1754    pub fn new_context_pack_fixture(
1755        config: NodeServerConfig,
1756        proof_path: PathBuf,
1757    ) -> Result<Self, NodeServerError> {
1758        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1759        Self::new_with_registry(config, catalog)
1760    }
1761
1762    #[cfg(all(feature = "fixture", windows))]
1763    pub fn new_monitoring_context_pack_fixture(
1764        config: NodeServerConfig,
1765        proof_path: PathBuf,
1766    ) -> Result<Self, NodeServerError> {
1767        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1768        let mut server = Self::new_with_registry(config, catalog)?;
1769        Arc::get_mut(&mut server.shared)
1770            .expect("fixture Node shared state must remain uniquely owned before run")
1771            .fixture_semantic_hook_policy = true;
1772        Ok(server)
1773    }
1774
1775    #[cfg(all(feature = "fixture", windows))]
1776    pub fn install_isolated_codex_home_environment_fixture(
1777        &mut self,
1778        profile_id: SpawnEnvironmentProfileId,
1779        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1780    ) -> Result<(), NodeServerError> {
1781        let provider = AgentId::new("codex")
1782            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1783        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1784            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1785                "{}-pty",
1786                profile_id.as_str(),
1787            ))
1788            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1789            provider.clone(),
1790            TransportKind::Pty,
1791            vec![OsString::from("USERPROFILE")],
1792            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1793        )
1794        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795        let materialization = NodeSessionMaterializationProfile::new(
1796            Vec::new(),
1797            vec![crate::session_environment::NodeSessionPathBinding::new(
1798                "CODEX_HOME",
1799                crate::session_environment::NodeSessionPathClass::ProviderHome,
1800            )
1801            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1802            Vec::new(),
1803        )
1804        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1805        self.install_environment_profile(
1806            NodeEnvironmentProfile::new_with_materialization(
1807                profile_id,
1808                profile_revision,
1809                provider,
1810                [native_profile],
1811                Some(materialization),
1812            )
1813            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1814        )
1815    }
1816
1817    #[cfg(all(feature = "fixture", windows))]
1818    pub fn new_context_only_proof_fixture(
1819        config: NodeServerConfig,
1820        target_provider: AgentId,
1821        proof_path: PathBuf,
1822    ) -> Result<Self, NodeServerError> {
1823        if target_provider.as_str() != "kimi" {
1824            return Err(NodeServerError::Registry(
1825                "context-only proof fixture requires Kimi".to_owned(),
1826            ));
1827        }
1828        let catalog = Self::context_pack_fixture_catalog(
1829            proof_path,
1830            Some(&target_provider),
1831            false,
1832        )?;
1833        Self::new_with_registry(config, catalog)
1834    }
1835
1836    #[cfg(all(feature = "fixture", windows))]
1837    fn context_pack_fixture_catalog(
1838        proof_path: PathBuf,
1839        context_only_target: Option<&AgentId>,
1840        monitoring_claude_source: bool,
1841    ) -> Result<AgentRegistry, NodeServerError> {
1842        if !proof_path.is_absolute()
1843            || !proof_path
1844                .parent()
1845                .is_some_and(|parent| parent.is_dir())
1846        {
1847            return Err(NodeServerError::Registry(
1848                "context pack proof path requires an existing absolute parent".to_owned(),
1849            ));
1850        }
1851        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1852            NodeServerError::Registry(
1853                "context pack proof path is not valid Unicode".to_owned(),
1854            )
1855        })?;
1856        let providers = ["claude", "codex", "grok", "kimi"];
1857        let builtins = builtin_registry();
1858        let mut specs = Vec::with_capacity(providers.len());
1859        for provider_id in providers {
1860            let agent_id = AgentId::new(provider_id)
1861                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1862            let provider = builtins.get(&agent_id).ok_or_else(|| {
1863                NodeServerError::Registry(
1864                    "context pack fixture provider adapter is unavailable".to_owned(),
1865                )
1866            })?;
1867            let monitored_provider =
1868                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1869            let mut spec = if monitored_provider {
1870                gate4agent_testkit::monitoring_hook_agent_spec()
1871            } else {
1872                gate4agent_testkit::interactive_agent_spec()
1873            };
1874            spec.id = agent_id.clone();
1875            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1876            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1877            spec.detection.aliases.clear();
1878            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1879            if provider_id == "codex" {
1880                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1881                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1882                })?;
1883                let validation = r#"
1884$bundleArgs = @($bundleArgs)
1885if ($bundleArgs.Count -ne 0) { exit 91 }
1886$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1887$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1888$cwd = (Get-Location).ProviderPath
1889if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1890$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1891if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1892$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1893if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1894$sha = [Security.Cryptography.SHA256]::Create()
1895try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1896if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1897if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1898$contextPath = Join-Path $contextRoot 'context-pack.json'
1899if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1900$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1901if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1902$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1903$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1904$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1905$separator = [IO.Path]::DirectorySeparatorChar
1906if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1907if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1908$rawContext = [IO.File]::ReadAllText($contextPath)
1909if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1910try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1911$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1912$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1913foreach ($property in @($document.PSObject.Properties.Name)) {
1914    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1915}
1916foreach ($property in $requiredTopLevel) {
1917    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1918}
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 107 }
1923foreach ($message in $messages) {
1924    $messageProperties = @($message.PSObject.Properties.Name)
1925    foreach ($property in $messageProperties) {
1926        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1927    }
1928    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1929}
1930$sha = [Security.Cryptography.SHA256]::Create()
1931try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1932[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))
1933$rawProof = [IO.File]::ReadAllText($proofPath)
1934if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1935[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1936"#;
1937                spec.launch.fixed_args.push(format!(
1938                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939                ));
1940                spec.launch.fixed_args.push(proof_path.clone());
1941            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1942                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1943                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1944                })?;
1945                let validation = r#"
1946$bundleArgs = @($bundleArgs)
1947if ($bundleArgs.Count -ne 0) { exit 91 }
1948$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1949$cwd = (Get-Location).ProviderPath
1950if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1951$contextPath = Join-Path $contextRoot 'context-pack.json'
1952if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1953$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1954if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1955$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1956$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1957$separator = [IO.Path]::DirectorySeparatorChar
1958if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1959try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1960if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1961if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1962$messages = @($document.retained_messages)
1963if ($messages.Count -eq 0) { exit 99 }
1964$hasUser = $false
1965$hasAssistant = $false
1966foreach ($message in $messages) {
1967    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1968    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1969    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1970    else { exit 101 }
1971}
1972if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1973$sha = [Security.Cryptography.SHA256]::Create()
1974try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1975[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1976[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1977"#;
1978                spec.launch.fixed_args.push(format!(
1979                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1980                ));
1981                spec.launch.fixed_args.push(proof_path.clone());
1982            }
1983            specs.push(spec);
1984        }
1985        let catalog = AgentRegistry::new(specs)
1986            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1987        Ok(catalog)
1988    }
1989
1990    #[cfg(feature = "fixture")]
1991    pub fn new_exact_launcher_fixture(
1992        config: NodeServerConfig,
1993        agent_id: AgentId,
1994        launcher: String,
1995    ) -> Result<Self, NodeServerError> {
1996        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1997            return Err(NodeServerError::Registry(
1998                "exact launcher fixture requires a supported provider".to_owned(),
1999            ));
2000        }
2001        if launcher.contains('\0') {
2002            return Err(NodeServerError::Registry(
2003                "exact launcher fixture path contains NUL".to_owned(),
2004            ));
2005        }
2006        let launcher_path = Path::new(&launcher);
2007        if !launcher_path.is_absolute() {
2008            return Err(NodeServerError::Registry(
2009                "exact launcher fixture path must be absolute".to_owned(),
2010            ));
2011        }
2012        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2013            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2014        })?;
2015        if !metadata.is_file() {
2016            return Err(NodeServerError::Registry(
2017                "exact launcher fixture path must be a regular file".to_owned(),
2018            ));
2019        }
2020        let mut spec = builtin_registry()
2021            .get(&agent_id)
2022            .ok_or_else(|| {
2023                NodeServerError::Registry(
2024                    "exact launcher fixture provider is unavailable".to_owned(),
2025                )
2026            })?
2027            .clone();
2028        spec.launch.program = launcher;
2029        Self::new_fixture_with_spec(config, spec)
2030    }
2031
2032    /// Shared H3B validation and spec construction for
2033    /// `new_harness_mcp_proxy_fixture` and
2034    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2035    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2036    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2037    /// the named builtin catalog entry -- detection command and expected
2038    /// process matcher rebased on the launcher's own basename, exactly the
2039    /// same shape `new_harness_mcp_proxy_fixture` always built.
2040    #[cfg(feature = "fixture")]
2041    fn h3b_provider_spec_with_program_and_args(
2042        agent_id: AgentId,
2043        provider_program: PathBuf,
2044        fixed_args: Vec<OsString>,
2045    ) -> Result<AgentSpec, NodeServerError> {
2046        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2047            return Err(NodeServerError::Registry(
2048                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2049            ));
2050        }
2051        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2052            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2053        })?;
2054        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2055            return Err(NodeServerError::Registry(
2056                "H3B fixture provider must be an exact regular file".to_owned(),
2057            ));
2058        }
2059        let program = provider_program.into_os_string().into_string().map_err(|_| {
2060            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2061        })?;
2062        let fixed_args = fixed_args.into_iter().map(|argument| {
2063            argument.into_string().map_err(|_| NodeServerError::Registry(
2064                "H3B fixture argument is not Unicode".to_owned(),
2065            ))
2066        }).collect::<Result<Vec<_>, _>>()?;
2067        let argument_bytes = fixed_args.iter()
2068            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2069        if program.contains('\0')
2070            || fixed_args.iter().any(|argument| argument.contains('\0'))
2071            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2072        {
2073            return Err(NodeServerError::Registry(
2074                "H3B fixture provider or arguments are invalid".to_owned(),
2075            ));
2076        }
2077        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2078            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2079        })?.clone();
2080        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2081            .ok_or_else(|| NodeServerError::Registry(
2082                "H3B fixture provider basename is unavailable".to_owned(),
2083            ))?.to_owned();
2084        spec.detection.command = process_name.clone();
2085        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2086            name: process_name,
2087        }];
2088        spec.launch.program = program;
2089        spec.launch.fixed_args = fixed_args;
2090        Ok(spec)
2091    }
2092
2093    #[cfg(feature = "fixture")]
2094    pub fn new_harness_mcp_proxy_fixture(
2095        config: NodeServerConfig,
2096        provider_program: PathBuf,
2097        fixed_args: Vec<OsString>,
2098        harness_mcp_program: PathBuf,
2099    ) -> Result<Self, NodeServerError> {
2100        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2101        let agent_id = AgentId::new("codex")
2102            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2103        let mut spec =
2104            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2105        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2106        config.fixture_raw_pty_runtime = true;
2107        Self::new_fixture_with_spec(config, spec)
2108    }
2109
2110    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2111    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2112    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2113    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2114    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2115    /// fixture_raw_pty_runtime = true`, which tears out the live
2116    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2117    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2118    /// hardcode `acp_transport = false` for every provider, refusing
2119    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2120    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2121    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2122    /// is built from the (cloned, launcher-replaced) catalog spec, and
2123    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2124    /// unmodified catalog fact: every builtin provider this accepts
2125    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2126    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2127    /// invented here -- this constructor only removes the fixture-only
2128    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2129    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2130    /// for every ACP session, fixture or not) is what actually grants the
2131    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2132    /// this constructor never calls it directly, and does not need to.
2133    #[cfg(feature = "fixture")]
2134    pub fn new_harness_mcp_acp_launcher_fixture(
2135        config: NodeServerConfig,
2136        helper_program: PathBuf,
2137        provider: AgentId,
2138        program: PathBuf,
2139        args: Vec<OsString>,
2140    ) -> Result<Self, NodeServerError> {
2141        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2142            return Err(NodeServerError::Registry(
2143                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2144            ));
2145        }
2146        let config = config.with_harness_mcp_helper(helper_program)?;
2147        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2148        let launch = spec.launch.clone();
2149        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2150            NodeServerError::Registry(
2151                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2152                    .to_owned(),
2153            )
2154        })?;
2155        // Routes the ACP spawn itself through the exact replaced launcher
2156        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2157        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2158        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2159        // without this the ACP transport ignores `spec.launch` entirely and
2160        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2161        // codex-acp@1.10.0`), which this fixture must never reach.
2162        acp.launch_override = Some(launch);
2163        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2164        Self::new_fixture_with_spec(config, spec)
2165    }
2166
2167    #[cfg(feature = "fixture")]
2168    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2169        let mut spec = gate4agent_testkit::interactive_agent_spec();
2170        let claude_id = AgentId::new("claude")
2171            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2172        let claude = builtin_registry()
2173            .get(&claude_id)
2174            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2175        spec.id = claude_id;
2176        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2177        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2178            name: "claude".to_owned(),
2179        }];
2180        spec.capabilities.transports.pty_adapter = claude
2181            .capabilities
2182            .transports
2183            .pty_adapter
2184            .clone();
2185        spec.capabilities.adapters.resume = claude
2186            .capabilities
2187            .adapters
2188            .resume
2189            .clone();
2190        let script = spec
2191            .launch
2192            .fixed_args
2193            .pop()
2194            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2195        spec.launch.fixed_args.push(format!(
2196            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2197        ));
2198        Self::new_fixture_with_spec(config, spec)
2199    }
2200
2201    #[cfg(feature = "fixture")]
2202    pub fn new_cmd_cwd_fixture(
2203        config: NodeServerConfig,
2204        command: String,
2205    ) -> Result<Self, NodeServerError> {
2206        let mut spec = gate4agent_testkit::interactive_agent_spec();
2207        spec.id = AgentId::new("claude")
2208            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2209        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2210        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2211        spec.launch.program = command;
2212        spec.launch.fixed_args.clear();
2213        Self::new_fixture_with_spec(config, spec)
2214    }
2215
2216    #[cfg(feature = "fixture")]
2217    fn new_fixture_with_spec(
2218        config: NodeServerConfig,
2219        spec: gate4agent_types::AgentSpec,
2220    ) -> Result<Self, NodeServerError> {
2221        let catalog = AgentRegistry::new([spec])
2222            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2223        Self::new_with_registry(config, catalog)
2224    }
2225
2226    fn new_with_registry(
2227        config: NodeServerConfig,
2228        catalog: AgentRegistry,
2229    ) -> Result<Self, NodeServerError> {
2230        for profile in config.spawn_profiles.iter() {
2231            if catalog.get(&profile.provider).is_none() {
2232                return Err(NodeServerError::Registry(
2233                    "spawn profile references an unavailable provider".to_owned(),
2234                ));
2235            }
2236        }
2237        let input_settle_timeout_ms = catalog
2238            .iter()
2239            .map(|spec| spec.readiness.timeout_ms)
2240            .max()
2241            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2242            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2243            .max(MUTATION_SETTLE_TIMEOUT_MS);
2244        let (provider_contracts, provider_adapter_contracts) =
2245            provider_contract_manifest(&catalog)?;
2246        let compatibility_support = node_compatibility_support_for_manifest(
2247            &provider_contracts,
2248            &provider_adapter_contracts,
2249        )?;
2250        validate_node_negotiated_handshake_capacity(&compatibility_support)
2251            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2252        let enabled_providers = provider_contracts
2253            .iter()
2254            .map(|contract| contract.provider.clone())
2255            .collect();
2256        #[cfg(feature = "fixture")]
2257        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2258        #[cfg(not(feature = "fixture"))]
2259        let fixture_raw_pty_runtime = false;
2260        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2261            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2262                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2263            })).expect("fixture provider catalog remains bounded");
2264            (None, statuses)
2265        } else {
2266            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2267            let statuses = monitor.collect();
2268            (Some(monitor), statuses)
2269        };
2270        let state_path_lock =
2271            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2272                .map_err(|error| {
2273                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2274                })?;
2275        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2276            Some(state_path) => {
2277                let delivery_root = delivery_store_root_for_state_path(state_path)
2278                    .ok_or_else(|| {
2279                        NodeServerError::DeliveryStore(
2280                            "delivery store root could not be derived from state path".to_owned(),
2281                        )
2282                    })?;
2283                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2284                    let detail = match &error {
2285                        DeliveryStoreError::Storage(io_error) => {
2286                            format!("{error}: {io_error}")
2287                        }
2288                        _ => error.to_string(),
2289                    };
2290                    NodeServerError::DeliveryStore(detail)
2291                })?;
2292                (Some(store), bundles)
2293            }
2294            None => (None, Vec::new()),
2295        };
2296        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2297            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2298        })?;
2299        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2300            Some(state_path) => {
2301                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2302                    .ok_or(NodeServerError::ContextPackStore)?;
2303                let (store, packs) = ContextPackStore::open(context_pack_root)
2304                    .map_err(|_| NodeServerError::ContextPackStore)?;
2305                (Some(store), packs)
2306            }
2307            None => (None, Vec::new()),
2308        };
2309        let mut durable_context_catalog = ContextPackCatalog::default();
2310        for pack in durable_context_packs {
2311            durable_context_catalog
2312                .insert(pack)
2313                .map_err(|_| NodeServerError::ContextPackStore)?;
2314        }
2315        let session_environment_materializer = config
2316            .session_environment
2317            .as_ref()
2318            .map(|environment| {
2319                SessionEnvironmentMaterializer::new(
2320                    environment.root.clone(),
2321                    Arc::clone(&environment.resolver),
2322                )
2323                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2324            })
2325            .transpose()?;
2326        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2327        // (the provider version probe above already used the original catalog).
2328        #[cfg(unix)]
2329        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2330        let native_session_catalog = config.history.clone().map(|history| {
2331            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2332        });
2333        let (handle, runtime) = match config.history.clone() {
2334            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2335            None => NativeRuntime::new(catalog, config.runtime),
2336        };
2337        let native_launch_profile_control = runtime.native_launch_profile_control();
2338        let harness_mcp_registry = config
2339            .harness_mcp_helper
2340            .clone()
2341            .map(HarnessMcpProxyRegistry::new);
2342        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2343        let incarnation_id = random_incarnation_id()
2344            .map_err(NodeServerError::IncarnationIdentity)?;
2345        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2346            .map_err(durable_state_load_error)?;
2347        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2348        let managed_worktree_tombstones =
2349            std::mem::take(&mut loaded.managed_worktree_tombstones);
2350        let materializations = std::mem::take(&mut loaded.materializations);
2351        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2352        let (workspaces, records, persistence_warning) =
2353            merge_durable_state(&config.workspaces, loaded)?;
2354        let mut shared = NodeShared::new_with_incarnation(
2355            handle,
2356            config.access_token.clone(),
2357            config.node_id.clone(),
2358            incarnation_id,
2359            workspaces,
2360            enabled_providers,
2361            provider_runtime_statuses,
2362            provider_runtime_monitor,
2363            provider_contracts,
2364            provider_adapter_contracts,
2365            config.spawn_profiles.clone(),
2366            Some(native_launch_profile_control),
2367            native_session_catalog,
2368            session_environment_materializer,
2369            config.state_path.clone(),
2370            records,
2371            managed_worktrees,
2372            managed_worktree_tombstones,
2373            materializations,
2374            persistence_warning,
2375            input_settle_timeout_ms,
2376        );
2377        shared.harness_mcp_registry = harness_mcp_registry.clone();
2378        shared.session_record_retention = config.session_record_retention;
2379        shared.network_allowlist_catalog =
2380            RwLock::new(config.network_allowlist_catalog.clone());
2381        shared.bundle_catalog = RwLock::new(delivered_catalog);
2382        shared.delivery_store = Mutex::new(delivery_store);
2383        shared.context_catalog = RwLock::new(durable_context_catalog);
2384        shared.context_pack_store = Mutex::new(context_pack_store);
2385        shared.managed_spawn_replays = Mutex::new(
2386            managed_spawn_replays
2387                .into_iter()
2388                .map(|record| (record.idempotency_key.clone(), record))
2389                .collect(),
2390        );
2391        let shared = Arc::new(shared);
2392        if let Some(registry) = harness_mcp_registry {
2393            let weak = Arc::downgrade(&shared);
2394            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2395                Some(shared) => shared.publish_transient(event),
2396                None => false,
2397            }));
2398        }
2399        if shared.persistence_error().is_none() {
2400            shared.persist_state().map_err(|error| {
2401                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2402            })?;
2403        }
2404        Ok(Self {
2405            config,
2406            runtime,
2407            shared,
2408            events,
2409            state_path_lock,
2410        })
2411    }
2412
2413    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2414        NodeShutdownHandle {
2415            shared: Arc::clone(&self.shared),
2416        }
2417    }
2418
2419    #[cfg(feature = "fixture")]
2420    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2421        &self,
2422    ) -> SpawnManagedWorktreeV2FailureProbe {
2423        self.shared
2424            .fixture_spawn_managed_worktree_v2_failure_probe
2425            .clone()
2426    }
2427
2428    /// Installs one immutable host-local environment profile before the server runs.
2429    ///
2430    /// Native resolvers remain inside the runtime; the shared node registry keeps
2431    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2432    pub fn install_environment_profile(
2433        &mut self,
2434        profile: NodeEnvironmentProfile,
2435    ) -> Result<(), NodeServerError> {
2436        if !self.shared.enabled_providers.contains(profile.provider()) {
2437            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2438                profile.provider().clone(),
2439            ));
2440        }
2441        let (binding, native_profiles, materialization) = profile.into_parts();
2442        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2443            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2444        }
2445        let mut profiles = self
2446            .shared
2447            .environment_profiles
2448            .write()
2449            .unwrap_or_else(|poisoned| poisoned.into_inner());
2450        if profiles.contains_key(&binding.id) {
2451            return Err(NodeServerError::DuplicateEnvironmentProfile(
2452                binding.id.clone(),
2453            ));
2454        }
2455        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2456            return Err(NodeServerError::EnvironmentProfileCapacity {
2457                max: MAX_NODE_ENVIRONMENT_PROFILES,
2458            });
2459        }
2460        for native_id in binding.native_profile_ids() {
2461            if profiles.values().any(|current| {
2462                current.native_profile_ids().any(|current_id| current_id == native_id)
2463            }) {
2464                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2465                    native_id.to_string(),
2466                ));
2467            }
2468        }
2469
2470        let mut installed = Vec::with_capacity(native_profiles.len());
2471        for native_profile in native_profiles {
2472            let native_id = native_profile.id().clone();
2473            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2474                for installed_id in installed.into_iter().rev() {
2475                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2476                }
2477                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2478            }
2479            installed.push(native_id);
2480        }
2481        if let Some(materialization) = materialization {
2482            self.shared
2483                .environment_materialization_profiles
2484                .write()
2485                .unwrap_or_else(|poisoned| poisoned.into_inner())
2486                .insert(binding.id.clone(), materialization);
2487        }
2488        profiles.insert(binding.id.clone(), binding);
2489        Ok(())
2490    }
2491
2492    /// Installs one immutable host-local bundle before the server runs.
2493    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2494        if self.shared.session_environment_materializer.is_none() {
2495            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2496        }
2497        let mut catalog = self
2498            .shared
2499            .bundle_catalog
2500            .write()
2501            .unwrap_or_else(|poisoned| poisoned.into_inner());
2502        catalog
2503            .insert_idempotent(bundle)
2504            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2505        Ok(())
2506    }
2507
2508    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2509        let shutdown = self.shutdown_handle();
2510        let run = self.run();
2511        tokio::pin!(run);
2512        tokio::select! {
2513            result = &mut run => result,
2514            signal = wait_for_ctrl_signal() => {
2515                signal?;
2516                let shutdown_result = shutdown.request_shutdown().await;
2517                let run_result = run.await;
2518                shutdown_result.and(run_result)
2519            }
2520        }
2521    }
2522
2523    pub async fn run(self) -> Result<(), NodeServerError> {
2524        let NodeServer {
2525            config,
2526            mut runtime,
2527            shared,
2528            events,
2529            state_path_lock: _state_path_lock,
2530        } = self;
2531        shared.reconcile_materializations();
2532        shared.reconcile_managed_worktrees().await;
2533        shared.retire_unavailable_session_records().await;
2534        shared.reconcile_context_pack_exports().await;
2535        {
2536            let restore = Arc::clone(&shared);
2537            tokio::spawn(async move {
2538                sleep(Duration::from_secs(3)).await;
2539                restore.restore_journalled_sessions().await;
2540                loop {
2541                    restore.refresh_session_journal();
2542                    if restore.shutdown.load(Ordering::Acquire) { break; }
2543                    sleep(Duration::from_secs(10)).await;
2544                }
2545            });
2546        }
2547        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2548        // observes sessions through ACP where a provider has it; there is no
2549        // hook ingress.
2550        let endpoint = config.endpoint.clone();
2551        let call_home = config.call_home;
2552        let call_home_identity = config.call_home_identity.clone();
2553        let api_listen = config.api_listen;
2554        let bridge_listen = config.bridge_listen;
2555        let bridge_underlay = config.bridge_underlay;
2556        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2557            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2558        let accept_shared = Arc::clone(&shared);
2559        let api_shared = Arc::clone(&shared);
2560        let bridge_shared = Arc::clone(&shared);
2561        let shutdown_shared = Arc::clone(&shared);
2562        let shutdown_timeout = Duration::from_millis(
2563            config.runtime.provider_shutdown_timeout_ms.max(1),
2564        );
2565        let result = {
2566            let runtime_loop = drive_runtime_until_shutdown(
2567                &mut runtime,
2568                events,
2569                Arc::clone(&shared),
2570                shutdown_timeout,
2571            );
2572            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2573            let api_loop = http_api::run(api_listen, api_shared);
2574            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2575            tokio::pin!(runtime_loop);
2576            tokio::pin!(accept_loop);
2577            tokio::pin!(api_loop);
2578            tokio::pin!(bridge_loop);
2579            tokio::select! {
2580                runtime_result = &mut runtime_loop => {
2581                    let shutdown_result = shutdown_shared
2582                        .begin_shutdown()
2583                        .await
2584                        .map_err(NodeServerError::ShutdownDispatch);
2585                    let (accept_result, api_result, bridge_result) =
2586                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2587                    match runtime_result {
2588                        Err(error) => Err(error),
2589                        Ok(()) => shutdown_result
2590                            .and(accept_result)
2591                            .and(api_result.map_err(NodeServerError::HttpApi))
2592                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2593                    }
2594                }
2595                accept_result = &mut accept_loop => {
2596                    let shutdown_result = shutdown_shared
2597                        .begin_shutdown()
2598                        .await
2599                        .map_err(NodeServerError::ShutdownDispatch);
2600                    let (runtime_result, api_result, bridge_result) =
2601                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2602                    match accept_result {
2603                        Err(error) => Err(error),
2604                        Ok(()) => shutdown_result
2605                            .and(runtime_result)
2606                            .and(api_result.map_err(NodeServerError::HttpApi))
2607                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2608                    }
2609                }
2610                api_result = &mut api_loop => {
2611                    let shutdown_result = shutdown_shared
2612                        .begin_shutdown()
2613                        .await
2614                        .map_err(NodeServerError::ShutdownDispatch);
2615                    let (runtime_result, accept_result, bridge_result) =
2616                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2617                    match api_result {
2618                        Err(error) => Err(NodeServerError::HttpApi(error)),
2619                        Ok(()) => shutdown_result
2620                            .and(runtime_result)
2621                            .and(accept_result)
2622                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2623                    }
2624                }
2625                bridge_result = &mut bridge_loop => {
2626                    let shutdown_result = shutdown_shared
2627                        .begin_shutdown()
2628                        .await
2629                        .map_err(NodeServerError::ShutdownDispatch);
2630                    let (runtime_result, accept_result, api_result) =
2631                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2632                    match bridge_result {
2633                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2634                        Ok(()) => shutdown_result
2635                            .and(runtime_result)
2636                            .and(accept_result)
2637                            .and(api_result.map_err(NodeServerError::HttpApi)),
2638                    }
2639                }
2640            }
2641        };
2642        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2643        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2644        // NodeShutdownHandle also clones that Arc, so without an explicit release
2645        // here a supervisor that keeps the shutdown handle cannot reopen the same
2646        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2647        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2648        if let Ok(mut guard) = shared.delivery_store.lock() {
2649            *guard = None;
2650        }
2651        if let Ok(mut guard) = shared.context_pack_store.lock() {
2652            *guard = None;
2653        }
2654        result
2655    }
2656}
2657
2658async fn drive_runtime_until_shutdown(
2659    runtime: &mut NativeRuntime,
2660    events: EventSubscription,
2661    shared: Arc<NodeShared>,
2662    shutdown_timeout: Duration,
2663) -> Result<(), NodeServerError> {
2664    // Published once, never per iteration. The worker loops inside
2665    // `NativeRuntime` fold into this profile continuously; turning it into
2666    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2667    let _ = shared
2668        .shell_efficiency_profile
2669        .set(runtime.shell_efficiency_profile());
2670    let mut shutdown_started = None;
2671    let mut last_retention_sweep = Instant::now();
2672    let mut last_provisional_reap = Instant::now();
2673    loop {
2674        // Every phase below is timed unconditionally -- see
2675        // `drive_loop_profile`'s own doc comment for why an always-on,
2676        // allocation-free ring is cheap enough to never need a feature
2677        // flag. This is the only place these numbers can be taken: the
2678        // phases are calls made directly from this loop, not something a
2679        // callee could time on our behalf.
2680        let runtime_tick_start = Instant::now();
2681        let tick_result = runtime.tick().await;
2682        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2683
2684        let event_drain_start = Instant::now();
2685        // Every command the kernel refused, named. `NativeRuntimeTick`
2686        // has carried these outcomes all along and nothing read them, so a
2687        // rejected command -- a hook event denied by runtime policy, a
2688        // stale generation, an unsupported capability -- vanished at the
2689        // last step of the loop that produced it. A refusal the operator
2690        // cannot see is indistinguishable from an event that never arrived,
2691        // and telling those two apart is most of debugging this stack.
2692        for outcome in &tick_result.command_outcomes {
2693            if let Err(error) = &outcome.result {
2694                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2695                tracing::warn!(
2696                    command_id = outcome.command_id.0,
2697                    cause = %error,
2698                    "control command rejected"
2699                );
2700                shared.record_rejected_command(
2701                    outcome.command_id,
2702                    error.to_string(),
2703                    error.is_unsupported_transport(),
2704                );
2705            }
2706        }
2707        let mut events_drained: u64 = 0;
2708        while let Ok(event) = events.try_recv() {
2709            events_drained += 1;
2710            let clean_exit = matches!(
2711                event.event,
2712                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2713            );
2714            let instance_id = event.instance_id;
2715            let generation = event.generation;
2716            // Resolve and spawn before publish_control: publish_control's own
2717            // managed-record bookkeeping (reconcile_managed_record) downgrades
2718            // this exact record from Live to Dormant and clears
2719            // active_session for every Exited event, inside the same
2720            // synchronous call. Capturing (record_id, session) here — before
2721            // that runs — is the only way to observe them still Live and
2722            // still populated; the export itself must still run on its own
2723            // detached task (never awaited inline on this loop) because it
2724            // needs NativeRuntime::tick() to keep running to ever settle, and
2725            // tick() is only ever driven by this same loop. See
2726            // reconcile_context_pack_export_for_record's doc comment.
2727            if clean_exit {
2728                if let Some((record_id, session)) =
2729                    shared.managed_record_export_target_for_instance(instance_id, generation)
2730                {
2731                    let export_shared = Arc::clone(&shared);
2732                    tokio::spawn(async move {
2733                        export_shared
2734                            .reconcile_context_pack_export_for_record(&record_id, session)
2735                            .await;
2736                    });
2737                }
2738            }
2739            shared.publish_control(event);
2740        }
2741        let event_drain_elapsed = event_drain_start.elapsed();
2742
2743        let publish_terminal_frames_start = Instant::now();
2744        shared.publish_terminal_frames();
2745        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2746
2747        // Physical PTY count and the runtime's own per-phase tick timings
2748        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2749        // only `NodeShared`) cannot reach directly -- this loop is the one
2750        // place holding `&mut NativeRuntime`, so it is the only place that
2751        // can copy either out.
2752        shared
2753            .native_session_gauge
2754            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2755        *shared
2756            .runtime_tick_profile
2757            .lock()
2758            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2759
2760        let remainder_start = Instant::now();
2761        if shared.shutdown.load(Ordering::Acquire) {
2762            let started = *shutdown_started.get_or_insert_with(Instant::now);
2763            let snapshot = shared.handle.snapshot();
2764            let sessions_settled = snapshot.sessions.iter().all(|session| {
2765                !matches!(
2766                    session.status,
2767                    gate4agent_types::SessionStatus::Starting
2768                        | gate4agent_types::SessionStatus::Running
2769                        | gate4agent_types::SessionStatus::Stopping
2770                )
2771            });
2772            if runtime.active_native_sessions() == 0 && sessions_settled {
2773                return Ok(());
2774            }
2775            if started.elapsed() >= shutdown_timeout {
2776                return Err(NodeServerError::ShutdownTimedOut {
2777                    active_native_sessions: runtime.active_native_sessions(),
2778                });
2779            }
2780        }
2781        let remainder_elapsed = remainder_start.elapsed();
2782
2783        {
2784            let mut profile = shared
2785                .drive_loop_profile
2786                .lock()
2787                .unwrap_or_else(|poisoned| poisoned.into_inner());
2788            profile.record_runtime_tick(runtime_tick_elapsed);
2789            profile.record_event_drain(event_drain_elapsed);
2790            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2791            profile.record_remainder(remainder_elapsed);
2792            profile.note_iteration(Instant::now());
2793        }
2794
2795        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2796        // applied zero observations (`tick_result.observations_applied`,
2797        // which already folds in any terminal frames collected -- see
2798        // `NativeEffectDispatcher::drain_observations`) AND the
2799        // control-event subscription drained above forwarded zero events.
2800        // Command ingress and dispatched effects are deliberately NOT part
2801        // of this definition: an idle node still runs `step_control_plane`
2802        // and every provider supervisor's `tick` regardless, every 10ms --
2803        // so "nothing" here means nothing OBSERVABLE left this iteration,
2804        // matching what a reader would assume from `drive_loop_iterations`'s
2805        // own field name, not that the tick itself was free of work.
2806        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2807        if tick_result.observations_applied == 0 && events_drained == 0 {
2808            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2809        }
2810
2811        // A sweep cadence, not a policy value -- see
2812        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2813        // policy itself (whether this does anything at all) lives entirely
2814        // in `NodeShared::session_record_retention`.
2815        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2816            shared.retire_unavailable_session_records().await;
2817            last_retention_sweep = Instant::now();
2818        }
2819        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2820        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2821            for address in shared.take_due_provisional_spawns() {
2822                shared.reap_provisional_spawn(address).await;
2823            }
2824            last_provisional_reap = Instant::now();
2825        }
2826
2827        // Fast-poll for 300ms after PTY input so its echo is read and published
2828        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2829        let hot = shared
2830            .pty_input_marks
2831            .lock()
2832            .unwrap_or_else(|poisoned| poisoned.into_inner())
2833            .values()
2834            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2835        if hot {
2836            sleep(Duration::from_millis(1)).await;
2837        } else {
2838            tokio::select! {
2839                _ = sleep(Duration::from_millis(10)) => {}
2840                _ = shared.pty_input_wake.notified() => {}
2841            }
2842        }
2843    }
2844}
2845
2846#[derive(Clone)]
2847pub struct NodeShutdownHandle {
2848    shared: Arc<NodeShared>,
2849}
2850
2851impl NodeShutdownHandle {
2852    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2853        self.shared
2854            .begin_shutdown()
2855            .await
2856            .map_err(NodeServerError::ShutdownDispatch)
2857    }
2858}
2859
2860async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2861    #[cfg(windows)]
2862    {
2863    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2864    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2865    return tokio::select! {
2866        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2867        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868    };
2869    }
2870    #[cfg(unix)]
2871    {
2872        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2873        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2874        tokio::select! {
2875            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2876            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2877        }
2878    }
2879}
2880
2881/// The providers this node actually runs.
2882///
2883/// `builtin_registry()` is a reference catalog of everything the project has
2884/// ever described; the fleet is this list, and it is deliberately short. A
2885/// provider only belongs here if it can be exercised for real -- there is a
2886/// subscription behind it and a failure in it can be reproduced and
2887/// debugged. Carrying one that cannot is carrying code nobody can verify,
2888/// which is worse than not supporting it: it looks supported.
2889///
2890/// A provider dropped from the fleet by this rule is removed outright, not
2891/// carried dark: no catalog entry, no adapter, no dead code claiming support
2892/// for something nobody can verify runs.
2893/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2894fn shell_host_spec() -> AgentSpec {
2895    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2896    // fleet inventory on op-box resolves `bash` to the WSL stub at
2897    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2898    #[cfg(windows)]
2899    let (detect_command, program, fixed_args, process_name) = {
2900        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2901            r"C:\Windows\System32\cmd.exe".to_owned()
2902        });
2903        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2904    };
2905    #[cfg(not(windows))]
2906    let (detect_command, program, fixed_args, process_name) = (
2907        "bash".to_owned(),
2908        "/bin/bash".to_owned(),
2909        vec!["-i".to_owned()],
2910        "bash".to_owned(),
2911    );
2912    AgentSpec {
2913        id: AgentId::new("shell").expect("shell agent id"),
2914        revision: "hatchery:shell-pty-1".to_owned(),
2915        display_name: "Host shell".to_owned(),
2916        detection: DetectionSpec {
2917            command: detect_command,
2918            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2919            required_commands: Vec::new(),
2920            unsupported_platforms: Vec::new(),
2921        },
2922        launch: LaunchSpec {
2923            program,
2924            fixed_args,
2925        },
2926        expected_processes: vec![ProcessMatcher::Exact {
2927            name: process_name,
2928        }],
2929        prompt: PromptSpec {
2930            initial: InitialPromptMode::None,
2931            native_draft: None,
2932        },
2933        readiness: AgentReadinessSpec::default(),
2934        capabilities: AgentCapabilities {
2935            agent_commands: None,
2936            transports: AgentTransportCapabilities {
2937                pty: true,
2938                pty_adapter: None,
2939                pipe: None,
2940                acp: None,
2941            },
2942            adapters: AgentAdapterCapabilities::default(),
2943        },
2944        verification: SpecVerification::Gate4AgentVerified,
2945    }
2946}
2947
2948fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2949    let mut specs = vec![shell_host_spec()];
2950    specs.extend(
2951        builtin_registry()
2952            .iter()
2953            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2954            .cloned(),
2955    );
2956    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2957}
2958
2959fn provider_contract_manifest(
2960    catalog: &AgentRegistry,
2961) -> Result<
2962    (
2963        Vec<ProviderContractSupport>,
2964        Vec<ProviderAdapterContractSupport>,
2965    ),
2966    NodeServerError,
2967> {
2968    let mut provider_contracts = Vec::new();
2969    let mut provider_adapter_contracts = Vec::new();
2970    for spec in catalog.iter() {
2971        let provider = spec.id.clone();
2972        let revision = ProviderContractRevision::new(spec.revision.clone())
2973            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2974        provider_contracts.push(ProviderContractSupport {
2975            provider: provider.clone(),
2976            revision,
2977        });
2978        for (family, binding) in declared_adapter_bindings(spec)? {
2979            let revision = AdapterContractRevision::new(binding.revision.clone())
2980                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2981            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2982                provider: provider.clone(),
2983                family,
2984                adapter_id: binding.id.clone(),
2985                revision,
2986            });
2987        }
2988    }
2989    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2990        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2991    Ok((provider_contracts, provider_adapter_contracts))
2992}
2993
2994fn declared_adapter_bindings(
2995    spec: &AgentSpec,
2996) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2997    let mut bindings = Vec::new();
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::PtySemantic,
3001        spec.capabilities.transports.pty_adapter.as_ref(),
3002    )?;
3003    push_declared_binding(
3004        &mut bindings,
3005        AdapterFamily::Pipe,
3006        spec.capabilities.adapters.pty_sidecar.as_ref(),
3007    )?;
3008    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3009    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3010        gate4agent_types::PipeProtocol::SemanticNdjson
3011        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3012        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3013    });
3014    push_declared_binding(
3015        &mut bindings,
3016        AdapterFamily::Pipe,
3017        pipe_transport
3018            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3019            .map(|transport| &transport.adapter),
3020    )?;
3021    push_declared_binding(
3022        &mut bindings,
3023        AdapterFamily::Acp,
3024        spec.capabilities
3025            .transports
3026            .acp
3027            .as_ref()
3028            .map(|transport| &transport.adapter),
3029    )?;
3030    push_declared_binding(
3031        &mut bindings,
3032        AdapterFamily::Hook,
3033        spec.capabilities.adapters.hook.as_ref(),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::ManagedHook,
3038        spec.capabilities.adapters.managed_hook.as_ref(),
3039    )?;
3040    push_declared_binding(
3041        &mut bindings,
3042        AdapterFamily::OneShot,
3043        pipe_transport
3044            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3045            .map(|transport| &transport.adapter),
3046    )?;
3047    push_declared_binding(
3048        &mut bindings,
3049        AdapterFamily::OneShot,
3050        spec.capabilities.adapters.one_shot.as_ref(),
3051    )?;
3052    push_declared_binding(
3053        &mut bindings,
3054        AdapterFamily::History,
3055        spec.capabilities.adapters.history.as_ref(),
3056    )?;
3057    push_declared_binding(
3058        &mut bindings,
3059        AdapterFamily::Resume,
3060        spec.capabilities.adapters.resume.as_ref(),
3061    )?;
3062    push_declared_binding(
3063        &mut bindings,
3064        AdapterFamily::SessionOptions,
3065        spec.capabilities.adapters.session_options.as_ref(),
3066    )?;
3067    push_declared_binding(
3068        &mut bindings,
3069        AdapterFamily::CapabilityProbe,
3070        spec.capabilities.adapters.capability_probe.as_ref(),
3071    )?;
3072    Ok(bindings)
3073}
3074
3075fn push_declared_binding<'a>(
3076    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3077    family: AdapterFamily,
3078    binding: Option<&'a AdapterBinding>,
3079) -> Result<(), NodeServerError> {
3080    let Some(binding) = binding else {
3081        return Ok(());
3082    };
3083    if let Some((_, existing)) = bindings
3084        .iter()
3085        .find(|(existing_family, _)| *existing_family == family)
3086    {
3087        if *existing == binding {
3088            return Ok(());
3089        }
3090        return Err(NodeServerError::ProviderContractManifest(format!(
3091            "provider declares conflicting {family:?} adapter bindings"
3092        )));
3093    }
3094    bindings.push((family, binding));
3095    Ok(())
3096}
3097
3098fn merge_durable_state(
3099    configured: &[WorkspaceConfig],
3100    mut loaded: LoadedNodeState,
3101) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3102    let mut workspaces = configured
3103        .iter()
3104        .cloned()
3105        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3106        .collect::<BTreeMap<_, _>>();
3107    let mut roots = workspaces
3108        .values()
3109        .map(|workspace| {
3110            (
3111                platform::root_identity(&workspace.canonical_root),
3112                workspace.workspace_id.clone(),
3113            )
3114        })
3115        .collect::<BTreeMap<_, _>>();
3116    let mut warning = loaded
3117        .warning
3118        .take()
3119        .map(|message| session_registry::sanitized_persistence_summary(&message));
3120
3121    for (workspace_id, canonical_root) in loaded.workspaces {
3122        if let Some(existing) = workspaces.get(&workspace_id) {
3123            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3124                return Err(NodeServerError::DurableState(io::Error::new(
3125                    io::ErrorKind::InvalidData,
3126                    DURABLE_STATE_CONFLICT_ERROR,
3127                )));
3128            }
3129            continue;
3130        }
3131        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3132            return Err(NodeServerError::DurableState(io::Error::new(
3133                io::ErrorKind::InvalidData,
3134                DURABLE_STATE_CONFLICT_ERROR,
3135            )));
3136        }
3137        if !Path::new(&canonical_root).is_dir() {
3138            warning.get_or_insert_with(|| {
3139                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3140            });
3141            continue;
3142        }
3143        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3144            NodeServerError::DurableState(io::Error::new(
3145                io::ErrorKind::InvalidData,
3146                DURABLE_STATE_CONFLICT_ERROR,
3147            ))
3148        })?;
3149        roots.insert(
3150            platform::root_identity(&workspace.canonical_root),
3151            workspace.workspace_id.clone(),
3152        );
3153        workspaces.insert(workspace_id, workspace);
3154    }
3155
3156    for record in &mut loaded.records {
3157        match workspaces.get(&record.workspace_id) {
3158            Some(workspace)
3159                if platform::roots_equal(
3160                    &workspace.canonical_root,
3161                    windows_path_text(&record.canonical_root),
3162                ) => {}
3163            Some(_) => {
3164                return Err(NodeServerError::DurableState(io::Error::new(
3165                    io::ErrorKind::InvalidData,
3166                    DURABLE_STATE_CONFLICT_ERROR,
3167                )));
3168            }
3169            None => {
3170                record.state = ManagedSessionState::Unavailable;
3171                record.active_session = None;
3172                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3173            }
3174        }
3175    }
3176    Ok((workspaces.into_values().collect(), loaded.records, warning))
3177}
3178
3179/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3180/// own doc for the mechanism this exists to close.
3181struct RejectedCommandReason {
3182    message: String,
3183    /// True when the kernel refused this command specifically because the
3184    /// agent does not declare the requested transport (`KernelCommandError::
3185    /// UnsupportedTransport`) -- lets the reader choose the named
3186    /// `NodeFailureCode::UnsupportedTransport` over the generic
3187    /// `BackendOperationFailed` bucket, so this one well-known rejection
3188    /// keeps its identity all the way to the operator wire.
3189    unsupported_transport: bool,
3190    expires_at: Instant,
3191}
3192
3193struct NodeShared {
3194    handle: Gate4AgentHandle,
3195    access_token: String,
3196    node_id: NodeId,
3197    incarnation_id: NodeIncarnationId,
3198    started_at_unix_ms: u64,
3199    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3200    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3201    managed_worktree_profiles:
3202        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3203    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3204    enabled_providers: Vec<AgentId>,
3205    provider_runtime_statuses: ProviderRuntimeStatuses,
3206    provider_runtime_status_updates:
3207        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3208    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3209    provider_contracts: Vec<ProviderContractSupport>,
3210    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3211    spawn_profiles: SpawnProfileRegistry,
3212    environment_profiles:
3213        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3214    environment_materialization_profiles:
3215        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3216    /// Station **network** allowlist catalog (opaque policy ids only).
3217    /// Empty by default — any `network_allowlist` on spawn must be registered
3218    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3219    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3220    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3221    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3222    /// Never holds cookies / OAuth / proxy credentials.
3223    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3224    /// Node-local exclusive dig2browser station profile leases (feature
3225    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3226    /// cleared on session end. Never ImportSession / cookies on C2.
3227    #[cfg(feature = "dig2-station-probe")]
3228    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3229    bundle_catalog: RwLock<BundleCatalog>,
3230    delivery_store: Mutex<Option<DeliveryStore>>,
3231    context_pack_store: Mutex<Option<ContextPackStore>>,
3232    context_catalog: RwLock<ContextPackCatalog>,
3233    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3234    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3235    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3236    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3237    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3238    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3239    /// nick -> the session that holds that m4a account (for letter wakes).
3240    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3241    /// m4a session key per runtime this node gave an account.
3242    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3243    /// Keys a resume hands to its new runtime (same nick and mailbox).
3244    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3245    /// Per-session grok leader sockets (never the operator's shared leader).
3246    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3247    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3248    /// instead of a bearer capability.
3249    #[cfg(unix)]
3250    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3251    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3252    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3253    managed_spawn_replays:
3254        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3255    controller: Mutex<Option<ControllerLease>>,
3256    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3257    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3258    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3259    /// ACP launch options waiting for the agent to advertise its config options.
3260    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3261    history: Mutex<NodeEventHistory>,
3262    event_tx: broadcast::Sender<NodeEventEnvelope>,
3263    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3265    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3266    /// exactly rather than riding the durable `event_tx` history (see
3267    /// `publish`'s own assertion): a subscriber who never negotiated
3268    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3269    /// these, the same guarantee `include_terminal_frame_events` already
3270    /// gives terminal frames.
3271    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3272    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3273    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3274    /// broadcast rather than a ride on the durable `event_tx` history. A
3275    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3276    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3277    /// finite capacity and its willingness to silently evict a lagging
3278    /// subscriber's backlog is exactly how the call vanished with neither
3279    /// side ever logging it (see `publish_transient`'s own doc).
3280    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3281    terminal_frame_watermarks:
3282        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3283    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3284    pty_input_wake: tokio::sync::Notify,
3285    /// Per session: when the last PTY input was dispatched (latency probe +
3286    /// a short fast-poll window so the echo is published within ~1ms).
3287    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3288    next_connection_id: AtomicU64,
3289    next_instance_id: AtomicU64,
3290    next_command_id: AtomicU64,
3291    shutdown: AtomicBool,
3292    shutdown_notify: Notify,
3293    preauth_slots: Arc<Semaphore>,
3294    authenticated_slots: Arc<Semaphore>,
3295    inspection_slots: Arc<Semaphore>,
3296    mutation_gate: AsyncMutex<()>,
3297    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3298    session_records: Mutex<SessionRecords>,
3299    state_path: Option<PathBuf>,
3300    persistence_error: RwLock<Option<String>>,
3301    state_transaction: Mutex<()>,
3302    input_settle_timeout_ms: u64,
3303    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3304    /// comment. Written once per iteration by
3305    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3306    drive_loop_profile: Mutex<DriveLoopProfiler>,
3307    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3308    /// so an overwrite each iteration is cheap. The runtime itself is not
3309    /// reachable from here (it is owned locally by
3310    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3311    /// tick timings reach `GET /metrics`.
3312    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3313    /// The live shell-efficiency profile, shared with the per-instance
3314    /// worker loops that fold into it.
3315    ///
3316    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3317    /// profile` above keeps: producing a snapshot sorts every ring behind
3318    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3319    /// field set at construction because `NodeShared` is built before the
3320    /// `NativeRuntime` that owns the profile exists -- the drive loop
3321    /// publishes it once, before its first iteration. Unset means the
3322    /// runtime has not started driving yet, which `GET /metrics` reports as
3323    /// empty series rather than as zeroes it never measured.
3324    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3325    /// Physical PTY sessions the native runtime currently owns
3326    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3327    /// loop iteration -- deliberately a separate count from
3328    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3329    /// session bookkeeping), since the two can disagree and the gap is
3330    /// itself a diagnostic signal `GET /metrics` exposes.
3331    native_session_gauge: AtomicUsize,
3332    /// Lifetime totals for the per-connection serve loop, summed across
3333    /// every live connection.
3334    ///
3335    /// The drive loop's own phase timings account for about ten
3336    /// microseconds an iteration on an idle node, yet the process burns a
3337    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3338    /// with none, 23% with two, measured). That gap is somewhere in
3339    /// serving a connection, and there was no way to see it: iterations
3340    /// says whether the loop is spinning rather than sleeping, and events
3341    /// says whether it is spinning over real work or over nothing.
3342    connection_loop_iterations: AtomicU64,
3343    connection_events_sent: AtomicU64,
3344    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3345    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3346    /// `NativeRuntime::tick` observations AND forwarded zero control
3347    /// events; see the exact "produced nothing" definition at the site in
3348    /// `drive_runtime_until_shutdown` that increments these. Read twice
3349    /// and divide by the interval, same rule as `connections` above --
3350    /// `idle / total` is backlog item 5's "wake on work" number.
3351    /// Lifetime count of control commands the kernel refused.
3352    rejected_commands_total: AtomicU64,
3353    /// The named reason behind a recent kernel rejection, by the
3354    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3355    /// command -- populated by `drive_runtime_until_shutdown` for every
3356    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3357    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3358    /// `CommandId` among them.
3359    ///
3360    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3361    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3362    /// problem -- full or disconnected -- never for the kernel refusing the
3363    /// command's content (an unsupported transport, an unknown agent, ...);
3364    /// that verdict exists only once `NativeRuntime::tick` has processed the
3365    /// command, one drive-loop iteration later, in a different task than
3366    /// whichever caller is awaiting the spawn. Without this map, a spawn
3367    /// wait loop that polls `self.handle.snapshot()` for a session that
3368    /// will now never appear has no way to learn why -- it can only spin
3369    /// until its own commit deadline and report a generic "did not commit"
3370    /// timeout, discarding a reason the kernel already named precisely (see
3371    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3372    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3373    drive_loop_iterations_total: AtomicU64,
3374    drive_loop_iterations_idle: AtomicU64,
3375    /// The operator-chosen age/keep-N policy the retention sweep applies to
3376    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3377    /// see `SessionRecordRetentionConfig`'s own doc comment.
3378    session_record_retention: SessionRecordRetentionConfig,
3379    /// Lifetime count of managed session records the retention sweep has
3380    /// retired. Same tier as `rejected_commands_total` above: a live,
3381    /// process-lifetime instrument, never persisted and never reloaded from
3382    /// `state-v1.json` -- a restart legitimately resets it, matching every
3383    /// other `*_total` counter here.
3384    retired_records_total: AtomicU64,
3385    #[cfg(feature = "fixture")]
3386    fixture_semantic_hook_policy: bool,
3387    #[cfg(feature = "fixture")]
3388    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3389}
3390
3391impl Drop for NodeShared {
3392    fn drop(&mut self) {
3393        SessionLifecycleHook::on_shutdown(self);
3394    }
3395}
3396
3397#[derive(Clone, Debug, Eq, PartialEq)]
3398struct SessionBinding {
3399    workspace_id: WorkspaceId,
3400    generation: SessionGeneration,
3401    runtime_policy: ProviderRuntimePolicy,
3402    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3403    record_id: Option<SessionRecordId>,
3404    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3405    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3406    bundle: Option<ResolvedBundleReceipt>,
3407    context: Option<ResolvedContextPackReceipt>,
3408    materialization_id: Option<MaterializationId>,
3409}
3410
3411#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3412enum SpawnRecordPolicy {
3413    ProviderIdentityOnly,
3414    Always,
3415}
3416
3417struct NativeEnvironmentSelectionGuard {
3418    control: Option<NativeLaunchProfileControl>,
3419    instance_id: AgentInstanceId,
3420}
3421
3422struct NativeInstanceOverlayGuard {
3423    control: Option<NativeLaunchProfileControl>,
3424    instance_id: AgentInstanceId,
3425}
3426
3427struct NativeHarnessMcpOverlayGuard {
3428    control: NativeLaunchProfileControl,
3429    instance_id: AgentInstanceId,
3430}
3431
3432impl NativeHarnessMcpOverlayGuard {
3433    fn retain(self) { std::mem::forget(self); }
3434}
3435
3436impl Drop for NativeHarnessMcpOverlayGuard {
3437    fn drop(&mut self) {
3438        self.control
3439            .clear_native_mcp_server_launch_overlay(self.instance_id);
3440    }
3441}
3442
3443impl NativeInstanceOverlayGuard {
3444    fn retain(mut self) {
3445        self.control = None;
3446    }
3447}
3448
3449impl Drop for NativeInstanceOverlayGuard {
3450    fn drop(&mut self) {
3451        if let Some(control) = self.control.as_ref() {
3452            control.clear_native_instance_launch_overlay(self.instance_id);
3453        }
3454    }
3455}
3456
3457enum PreparedNativeLaunchOverlay {
3458    Environment(NativeLaunchEnvironmentOverlay),
3459    Instance(NativeInstanceLaunchOverlay),
3460}
3461
3462impl NativeEnvironmentSelectionGuard {
3463    fn retain(mut self) {
3464        self.control = None;
3465    }
3466}
3467
3468impl Drop for NativeEnvironmentSelectionGuard {
3469    fn drop(&mut self) {
3470        if let Some(control) = self.control.as_ref() {
3471            control.clear_native_launch_profile_selection(self.instance_id);
3472        }
3473    }
3474}
3475
3476struct SessionMaterializationGuard<'a> {
3477    shared: &'a NodeShared,
3478    id: Option<MaterializationId>,
3479    /// Absolute ProviderHome (`home/`) under the session materialization root.
3480    /// Used for Claude station network `--settings` sidecar writes.
3481    provider_home: Option<PathBuf>,
3482}
3483
3484impl SessionMaterializationGuard<'_> {
3485    fn id(&self) -> Option<&MaterializationId> {
3486        self.id.as_ref()
3487    }
3488
3489    fn provider_home(&self) -> Option<&Path> {
3490        self.provider_home.as_deref()
3491    }
3492
3493    fn retain(mut self) {
3494        self.id = None;
3495    }
3496}
3497
3498impl Drop for SessionMaterializationGuard<'_> {
3499    fn drop(&mut self) {
3500        if let Some(id) = self.id.take() {
3501            let _ = self.shared.cleanup_materialization(&id);
3502        }
3503    }
3504}
3505
3506
3507/// Port: product-side reactions to session lifecycle transitions
3508/// (station lease, harness MCP overlay/registry, environment launch overlays).
3509trait SessionLifecycleHook {
3510    /// A binding was removed (unbind or spawn rollback).
3511    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3512    /// A session reached a terminal control event that revokes its harness MCP door.
3513    fn on_session_ended(&self, address: &SessionAddress);
3514    /// Node shutdown (or drop): revoke every harness MCP reservation.
3515    fn on_shutdown(&self);
3516    /// A harness MCP reservation was aborted before activation.
3517    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3518    /// Release session-owned materialization for a removed binding.
3519    fn cleanup_session_materialization(
3520        &self,
3521        address: &SessionAddress,
3522        binding: &SessionBinding,
3523    ) -> Result<(), NodeFailure>;
3524}
3525
3526impl SessionLifecycleHook for NodeShared {
3527    fn on_session_ended(&self, address: &SessionAddress) {
3528        self.retire_session_grok_leader(address.session.instance_id);
3529        if !self.shutdown.load(Ordering::Acquire) {
3530            if let Some(file) = self.session_resume_journal() {
3531                crate::pty_resume::remove(&file, address.session.instance_id.0);
3532            }
3533        }
3534        if let Some(accounts) = self.m4a_accounts.as_ref() {
3535            let instance = address.session.instance_id;
3536            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3537            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3538                accounts.remove_session_file(nick);
3539            }
3540        }
3541        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3542            registry.revoke_session(address);
3543        }
3544        if let Some(control) = self.native_launch_profile_control.as_ref() {
3545            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3546        }
3547    }
3548
3549    fn on_shutdown(&self) {
3550        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3551            .into_values()
3552            .collect();
3553        for socket in leaders {
3554            crate::grok_leader::retire(&socket);
3555        }
3556        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3557            let instances = registry.shutdown();
3558            if let Some(control) = self.native_launch_profile_control.as_ref() {
3559                for instance_id in instances {
3560                    control.clear_native_mcp_server_launch_overlay(instance_id);
3561                }
3562            }
3563        }
3564    }
3565
3566    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3567        if let Some(control) = self.native_launch_profile_control.as_ref() {
3568            control.clear_native_mcp_server_launch_overlay(instance_id);
3569        }
3570    }
3571
3572    fn cleanup_session_materialization(
3573        &self,
3574        address: &SessionAddress,
3575        binding: &SessionBinding,
3576    ) -> Result<(), NodeFailure> {
3577        self.cleanup_session_owned_materialization(address, binding)
3578    }
3579
3580    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3581        // Exclusive dig2browser station profile lease is node-local; clear
3582        // on every session unbind / rollback. Never ImportSession/cookies.
3583        #[cfg(feature = "dig2-station-probe")]
3584        {
3585            crate::dig2_station_lease::release_lease_for_holder(
3586                &self.browser_station_leases,
3587                address.session.instance_id,
3588            );
3589        }
3590        if let Some(control) = self.native_launch_profile_control.as_ref() {
3591            control.clear_native_mcp_server_launch_overlay(
3592                address.session.instance_id,
3593            );
3594        }
3595        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3596            registry.revoke_session(address);
3597        }
3598        if removed
3599            .is_some_and(|binding| {
3600                binding.environment_profile.is_some()
3601                    || binding.bundle.is_some()
3602                    || binding.context.is_some()
3603            })
3604        {
3605            if let Some(control) = self.native_launch_profile_control.as_ref() {
3606                control.clear_native_launch_profile_selection(
3607                    address.session.instance_id,
3608                );
3609                control.clear_native_instance_launch_overlay(
3610                    address.session.instance_id,
3611                );
3612            }
3613        }
3614    }
3615}
3616
3617struct SpawnPrepareRequest<'r> {
3618    instance_id: AgentInstanceId,
3619    address: &'r SessionAddress,
3620    provider: &'r AgentId,
3621    mode: SessionMode,
3622    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3623    bundle: Option<&'r ResolvedBundleReceipt>,
3624    context: Option<&'r ResolvedContextPackReceipt>,
3625    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3626    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3627    approval_level: ApprovalLevel,
3628    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3629    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3630}
3631
3632/// Guards held between "session identity minted" and "launch committed".
3633/// Field order is the drop order and matches the pre-refactor local order
3634/// (instance overlay, environment selection, materialization, harness, station lease).
3635struct PreparedSpawn<'a> {
3636    instance_overlay: Option<NativeInstanceOverlayGuard>,
3637    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3638    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3639    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3640    #[cfg(feature = "dig2-station-probe")]
3641    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3642    materialization_id: Option<MaterializationId>,
3643}
3644
3645impl PreparedSpawn<'_> {
3646    /// Session-owned launch state now belongs to the binding.
3647    fn retain_session_guards(&mut self) {
3648        if let Some(selection) = self.environment_selection.take() {
3649            selection.retain();
3650        }
3651        if let Some(overlay) = self.instance_overlay.take() {
3652            overlay.retain();
3653        }
3654        if let Some(guard) = self.materialization_guard.take() {
3655            guard.retain();
3656        }
3657    }
3658
3659    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3660    fn retain_launch_guards(&mut self) {
3661        if let Some(overlay) = self.harness_mcp_overlay.take() {
3662            overlay.retain();
3663        }
3664        #[cfg(feature = "dig2-station-probe")]
3665        if let Some(guard) = self.browser_station_lease_guard.take() {
3666            guard.retain();
3667        }
3668    }
3669}
3670
3671/// Port: everything product-side that must be prepared before a session is
3672/// bound and started (station lease, harness MCP overlay, materialization,
3673/// environment selection, network overlays, launch overlay).
3674trait SpawnPreparer {
3675    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3676    /// Select the native launch profile for an environment-profile session.
3677    fn select_environment(
3678        &self,
3679        instance_id: AgentInstanceId,
3680        provider: &AgentId,
3681        mode: SessionMode,
3682        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3683    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3684    /// Install a prepared launch overlay for the instance.
3685    fn install_launch_overlay(
3686        &self,
3687        instance_id: AgentInstanceId,
3688        overlay: PreparedNativeLaunchOverlay,
3689    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3690}
3691
3692impl NodeShared {
3693    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3694    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3695    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3696        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3697        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3698        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3699        if let Some(file) = self.session_resume_journal() {
3700            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3701        }
3702        key
3703    }
3704
3705    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3706        let accounts = self.m4a_accounts.as_ref()?;
3707        let nick = self.m4a_nick_of(instance_id)?;
3708        match accounts.session_workdir(root, provider.as_str(), &nick) {
3709            Ok(dir) => dir,
3710            Err(error) => {
3711                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3712                None
3713            }
3714        }
3715    }
3716
3717    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3718        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3719    }
3720
3721    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3722    /// `/api/spawn` returns it).
3723    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3724        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3725        for record in records.records.values_mut() {
3726            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3727                record.display_name = nick.to_owned();
3728            }
3729        }
3730    }
3731
3732    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3733    /// the mailbox listener that wakes it.
3734    fn m4a_session_overlay(
3735        &self,
3736        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3737        m4a_provider: &str,
3738        provider: &AgentId,
3739        instance_id: AgentInstanceId,
3740        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3741        session_key: &str,
3742        mode: SessionMode,
3743    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3744        let session_key = session_key.to_owned();
3745        if mode == SessionMode::Acp {
3746            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3747        }
3748        Ok(                match accounts.provision(m4a_provider, &session_key) {
3749                    Ok(account) => {
3750                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3751                        let mut identity = account.env_mutations();
3752                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3753                        let merged = match environment_overlay {
3754                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3755                                .map(PreparedNativeLaunchOverlay::Instance),
3756                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3757                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3758                            }
3759                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3760                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3761                            }
3762                        }
3763                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3764                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3765                        let mut merged = merged;
3766                        match accounts.write_session_file(&account) {
3767                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3768                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3769                                Ok(args) if !args.is_empty() => {
3770                                    merged = match merged {
3771                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3772                                            .append_extra_args(args)
3773                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3774                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3775                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3776                                            .into_instance_with_extra_args(args)
3777                                            .map(PreparedNativeLaunchOverlay::Instance)
3778                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3779                                    };
3780                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3781                                }
3782                                Ok(_) => {}
3783                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3784                            },
3785                        }
3786                        self.m4a_live
3787                            .lock()
3788                            .unwrap_or_else(|p| p.into_inner())
3789                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3790                        self.name_record_after_nick(instance_id, &account.nick);
3791                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3792                        // Letters to this account wake the session: the node listens on its mailbox.
3793                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3794                        Some(merged)
3795                    }
3796                    Err(error) => {
3797                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3798                        environment_overlay
3799                    }
3800                })
3801    }
3802}
3803
3804impl NodeShared {
3805    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3806    /// handed to the agent in session/new, with the account env on that server only.
3807    fn m4a_acp_mcp_overlay(
3808        &self,
3809        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3810        m4a_provider: &str,
3811        provider: &AgentId,
3812        instance_id: AgentInstanceId,
3813        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3814        session_key: &str,
3815    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3816        let Some(control) = self.native_launch_profile_control.as_ref() else {
3817            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3818            return Ok(environment_overlay);
3819        };
3820        let account = match accounts.provision(m4a_provider, session_key) {
3821            Ok(a) => a,
3822            Err(error) => {
3823                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3824                return Ok(environment_overlay);
3825            }
3826        };
3827        let session_file = match accounts.write_session_file(&account) {
3828            Ok(p) => p,
3829            Err(error) => {
3830                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3831                return Ok(environment_overlay);
3832            }
3833        };
3834        let env_entries: Vec<(OsString, OsString)> = account
3835            .env_mutations()
3836            .into_iter()
3837            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3838            .filter_map(|m| m.value.map(|v| (m.key, v)))
3839            .collect();
3840        let args: Vec<OsString> = vec![
3841            "--session-file".into(),
3842            session_file.into_os_string(),
3843            "mcp".into(),
3844            "--provider".into(),
3845            provider.as_str().into(),
3846        ];
3847        let overlay = NativeMcpServerLaunchOverlay::new(
3848            provider.clone(),
3849            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3850            accounts.bin().as_os_str().to_os_string(),
3851            args,
3852            env_entries,
3853            Vec::new(),
3854        );
3855        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())) {
3856            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3857            Err(error) => {
3858                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3859                return Ok(environment_overlay);
3860            }
3861        }
3862        self.m4a_live
3863            .lock()
3864            .unwrap_or_else(|p| p.into_inner())
3865            .insert(account.nick.clone(), (instance_id, provider.clone()));
3866        self.name_record_after_nick(instance_id, &account.nick);
3867        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3868        Ok(environment_overlay)
3869    }
3870}
3871
3872impl SpawnPreparer for NodeShared {
3873    fn select_environment(
3874        &self,
3875        instance_id: AgentInstanceId,
3876        provider: &AgentId,
3877        mode: SessionMode,
3878        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3879    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3880        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3881    }
3882
3883    fn install_launch_overlay(
3884        &self,
3885        instance_id: AgentInstanceId,
3886        overlay: PreparedNativeLaunchOverlay,
3887    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3888        self.install_prepared_launch_overlay(instance_id, overlay)
3889    }
3890
3891    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3892        let SpawnPrepareRequest {
3893            instance_id,
3894            address,
3895            provider,
3896            mode,
3897            environment_profile,
3898            bundle,
3899            context,
3900            managed_authority,
3901            harness_mcp,
3902            approval_level,
3903            network_allowlist,
3904            browser_profile_id,
3905        } = request;
3906        // Track A follow-on: exclusive node-local BrowserStationLease after
3907        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3908        // never ImportSession / cookies on C2. Linux/feature-off: probe
3909        // already refused or stub; this block is cfg'd out when feature off.
3910        #[cfg(feature = "dig2-station-probe")]
3911        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3912            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3913            match acquire_lease(
3914                &self.browser_station_leases,
3915                profile_id,
3916                instance_id,
3917                unix_time_ms(),
3918            ) {
3919                Ok(guard) => Some(guard),
3920                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3921                    return Err(failure(
3922                        NodeFailureCode::BrowserStationProfileBusy,
3923                        "dig2browser station profile is already leased by another session",
3924                    ));
3925                }
3926                Err(BrowserStationLeaseError::CapacityExceeded) => {
3927                    return Err(failure(
3928                        NodeFailureCode::BrowserStationProfileBusy,
3929                        "dig2browser station profile lease capacity exhausted",
3930                    ));
3931                }
3932                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3933                    return Err(failure(
3934                        NodeFailureCode::BrowserStationProfileBusy,
3935                        "session already holds a dig2browser station profile lease",
3936                    ));
3937                }
3938            }
3939        } else {
3940            None
3941        };
3942        #[cfg(not(feature = "dig2-station-probe"))]
3943        let _ = browser_profile_id;
3944        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3945            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3946            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3947            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3948            // both read the identical overlay resolved below, one as child
3949            // environment, the other translated into one stdio MCP server
3950            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3951            // only, and nothing downstream reads a harness-MCP overlay for
3952            // that transport yet.
3953            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3954            {
3955                return Err(failure(
3956                    NodeFailureCode::BindingMismatch,
3957                    "harness MCP reservation does not match the exact PTY/ACP provider",
3958                ));
3959            }
3960            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3961            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3962                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3963            })?;
3964            // Every name below is the caller's, carried in the reservation's
3965            // launch description: which environment variables receive the
3966            // endpoint, token and helper program, what the MCP server is called,
3967            // which argv it gets and which stale variables are erased.
3968            let launch = prepared.launch();
3969            let mut env_entries = vec![
3970                (
3971                    OsString::from(launch.endpoint_env.as_str()),
3972                    prepared.endpoint().as_os_str().to_os_string(),
3973                ),
3974                (
3975                    OsString::from(launch.token_env.as_str()),
3976                    prepared.token().expose().into(),
3977                ),
3978                (
3979                    OsString::from(launch.program_env.as_str()),
3980                    prepared.helper_program().as_os_str().to_os_string(),
3981                ),
3982            ];
3983            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3984                env_entries.push(trace_entry);
3985            }
3986            if let Some(door) = crate::door::door_listen_addr() {
3987                env_entries.push((
3988                    OsString::from("HATCHERY_NODE_DOOR"),
3989                    OsString::from(door.to_string()),
3990                ));
3991                // Same key shape as HQ browser/draft ids:
3992                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3993                // slot across node boots when the instance counter resets.
3994                let session_key = format!(
3995                    "pty-{}-{}-{}",
3996                    self.incarnation_id,
3997                    address.workspace_id.as_str(),
3998                    address.session.instance_id.0
3999                );
4000                env_entries.push((
4001                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4002                    OsString::from(session_key),
4003                ));
4004            }
4005            let overlay = NativeMcpServerLaunchOverlay::new(
4006                provider.clone(),
4007                launch.server_name.as_str(),
4008                prepared.helper_program().as_os_str().to_os_string(),
4009                launch.args.iter().map(OsString::from).collect(),
4010                env_entries,
4011                launch.scrub_env.iter().map(OsString::from).collect(),
4012            ).map_err(|_| failure(
4013                NodeFailureCode::HarnessMcpUnavailable,
4014                "harness MCP launch overlay is unavailable",
4015            ))?;
4016            control
4017                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4018                .map_err(|_| failure(
4019                    NodeFailureCode::HarnessMcpUnavailable,
4020                    "harness MCP launch overlay could not be installed",
4021                ))?;
4022            // State-change line: the overlay is installed and will be read
4023            // back by the PTY child's environment or, for ACP,
4024            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4025            // time -- no secrets here, only which instance/provider/mode
4026            // now carries a harness-MCP door.
4027            tracing::info!(
4028                node_id = %self.node_id,
4029                instance_id = ?instance_id,
4030                provider = %provider,
4031                mode = ?mode,
4032                "harness MCP overlay installed for spawn",
4033            );
4034            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4035        } else {
4036            None
4037        };
4038        let prepared_materialization = self.prepare_session_materialization(
4039            &address,
4040            &provider,
4041            mode,
4042            environment_profile,
4043            bundle,
4044            context,
4045            managed_authority.cloned(),
4046        )?;
4047        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4048            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4049            None => (None, None),
4050        };
4051        let materialization_id = materialization_guard
4052            .as_ref()
4053            .and_then(SessionMaterializationGuard::id)
4054            .cloned();
4055        let mut environment_selection = SpawnPreparer::select_environment(self, 
4056            instance_id,
4057            &provider,
4058            mode,
4059            environment_profile,
4060        )?;
4061        // Station network overlays: Codex `-c network_access` and/or Claude
4062        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4063        let provider_home = materialization_guard
4064            .as_ref()
4065            .and_then(SessionMaterializationGuard::provider_home);
4066        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4067            &provider,
4068            mode,
4069            approval_level,
4070            network_allowlist,
4071            provider_home,
4072        )?;
4073        let mut network_overlay_args = network_overlay_args;
4074        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4075        {
4076            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4077            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4078            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4079                homes.extend(std::env::split_paths(&extra));
4080            }
4081            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4082            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4083            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4084        }
4085        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4086            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4087        }
4088        // Session registry: journal the session so it is resumed after a node restart.
4089        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4090            if let Some(file) = self.session_resume_journal() {
4091                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4092                    instance_id: instance_id.0,
4093                    provider: provider.as_str().to_owned(),
4094                    workspace_id: address.workspace_id.as_str().to_owned(),
4095                    provider_session_id: String::new(),
4096                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4097                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4098                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4099                    cwd: String::new(),
4100                    acp: mode == SessionMode::Acp,
4101                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4102                    cols: 0,
4103                    rows: 0,
4104                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106                });
4107                if let Some(dir) = file.parent() {
4108                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4109                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4110                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4111                        provider: Some(provider.as_str().to_owned()),
4112                        ..crate::session_meta::Meta::default()
4113                    });
4114                    crate::session_meta::revive(dir, instance_id.0);
4115                }
4116                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4117            }
4118        }
4119        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4120            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4121        }
4122        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4123            (prepared, args) if args.is_empty() => prepared,
4124            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4125                overlay.append_extra_args(args).map_err(|_| {
4126                    failure(
4127                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4128                        "network allowlist overlay could not merge with instance launch args",
4129                    )
4130                })?;
4131                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4132            }
4133            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4134                Some(PreparedNativeLaunchOverlay::Instance(
4135                    overlay
4136                        .into_instance_with_extra_args(args)
4137                        .map_err(|_| {
4138                            failure(
4139                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140                                "network allowlist overlay could not merge with environment overlay",
4141                            )
4142                        })?,
4143                ))
4144            }
4145            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4146                NativeInstanceLaunchOverlay::new(
4147                    provider.clone(),
4148                    match mode {
4149                        SessionMode::Pty => TransportKind::Pty,
4150                        SessionMode::Acp => TransportKind::Acp,
4151                        SessionMode::Inline => TransportKind::Pipe,
4152                    },
4153                    Vec::new(),
4154                    args,
4155                )
4156                .map_err(|_| {
4157                    failure(
4158                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4159                        "network allowlist overlay is invalid for this transport",
4160                    )
4161                })?,
4162            )),
4163        };
4164        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4165        // overlay or appended to the overlay the spawn already carries.
4166        #[cfg(unix)]
4167        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4168        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4169            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4170        });
4171        #[cfg(unix)]
4172        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4173        // signer handle is for sessions without one (operator shells).
4174        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4175        #[cfg(unix)]
4176        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4177            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4178                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4179                let uid = unsafe { libc_geteuid() };
4180                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4181                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4182                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4183                Some(vec![
4184                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4185                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4186                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4187                ])
4188            }
4189            _ => None,
4190        };
4191        #[cfg(not(unix))]
4192        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4193        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4194            (SessionMode::Pty, _, _, Some(identity)) => {
4195                let merged = match environment_overlay {
4196                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4197                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4198                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4199                    }
4200                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4201                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4202                    }
4203                }
4204                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4205                Some(merged)
4206            }
4207            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4208                let session_key = self.m4a_session_key(instance_id);
4209                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4210            }
4211            (SessionMode::Pty, Some(_), None, _) => {
4212                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4213                environment_overlay
4214            }
4215            _ => environment_overlay,
4216        };
4217        let mut instance_overlay = environment_overlay
4218            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4219            .transpose()?
4220            .flatten();
4221        Ok(PreparedSpawn {
4222            instance_overlay,
4223            environment_selection,
4224            materialization_guard,
4225            harness_mcp_overlay,
4226            #[cfg(feature = "dig2-station-probe")]
4227            browser_station_lease_guard,
4228            materialization_id,
4229        })
4230    }
4231}
4232
4233struct SessionRecords {
4234    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4235}
4236
4237struct SpawnIdempotencyCache {
4238    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4239}
4240
4241struct SpawnIdempotencyEntry {
4242    value: SpawnIdempotencyValue,
4243    expires_at: Instant,
4244}
4245
4246enum SpawnIdempotencyValue {
4247    Standard {
4248        spec: SpawnSpec,
4249        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4250    },
4251    Managed {
4252        request: ManagedWorktreeSpawnRequest,
4253        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4254    },
4255    HarnessMcp,
4256}
4257
4258enum DurableManagedSpawnReplayDecision {
4259    Reserved,
4260    PendingLinked,
4261    Committed(ManagedWorktreeSpawnReceipt),
4262}
4263
4264impl SpawnIdempotencyValue {
4265    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4266        match self {
4267            Self::Standard { result, .. } => result
4268                .as_ref()
4269                .ok()
4270                .and_then(|receipt| receipt.context.as_ref()),
4271            Self::Managed { result, .. } => result
4272                .as_ref()
4273                .ok()
4274                .and_then(|receipt| receipt.spawn.context.as_ref()),
4275            Self::HarnessMcp => None,
4276        }
4277        .is_some_and(|receipt| &receipt.id == context_id)
4278    }
4279}
4280
4281#[derive(Clone, Copy)]
4282struct ControllerLease {
4283    connection_id: u64,
4284    expires_at: Instant,
4285}
4286
4287struct NodeEventHistory {
4288    last_sequence: u64,
4289    replay_floor_sequence: u64,
4290    events: VecDeque<NodeEventEnvelope>,
4291    record_providers: BTreeMap<SessionRecordId, AgentId>,
4292    removed_record_providers: BTreeMap<u64, AgentId>,
4293}
4294
4295impl NodeEventHistory {
4296    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4297        Self {
4298            last_sequence: 0,
4299            replay_floor_sequence: 1,
4300            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4301            record_providers,
4302            removed_record_providers: BTreeMap::new(),
4303        }
4304    }
4305}
4306
4307impl NodeShared {
4308    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4309    /// as a descendant of this session's process (with the session's own environment) and
4310    /// never attaches to an operator's shared leader. Same grok home, same login.
4311    fn session_resume_journal(&self) -> Option<PathBuf> {
4312        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4313    }
4314
4315    /// Node start: resume every journalled session through its provider's resume path,
4316    /// under its old instance number, so HQ shows the same chat.
4317    async fn restore_journalled_sessions(&self) {
4318        let Some(file) = self.session_resume_journal() else { return };
4319        if !crate::pty_resume::enabled() { return; }
4320        for e in crate::pty_resume::load(&file) {
4321            crate::pty_resume::remove(&file, e.instance_id);
4322            // Same m4a session key as before the restart: the session keeps its account and nick.
4323            if !e.session_key.is_empty() {
4324                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4325            }
4326            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4327            if e.acp {
4328                let record_id = {
4329                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4330                    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())
4331                };
4332                let cur = self.next_instance_id.load(Ordering::Acquire);
4333                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4334                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4335                let resumed = match record_id {
4336                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4337                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4338                };
4339                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4340                let r = match resumed {
4341                    Ok(a) => Ok(a),
4342                    Err(err) => {
4343                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4344                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4345                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4346                    }
4347                };
4348                match r {
4349                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4350                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4351                }
4352                continue;
4353            }
4354            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4355                // Nothing said yet in that chat: start it fresh under the same number.
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: 40, columns: 120 };
4359                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4360                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4361                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4362                }
4363                continue;
4364            }
4365            let folder = std::path::Path::new(&e.cwd);
4366            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4367            let name = match (in_session_dir, folder.file_name()) {
4368                (true, Some(n)) => n.to_string_lossy().into_owned(),
4369                _ => format!("{} #{}", e.provider, e.instance_id),
4370            };
4371            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4372            let record = match self.index_provider_session(ws, provider, identity, name) {
4373                Ok(r) => r,
4374                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4375            };
4376            let cur = self.next_instance_id.load(Ordering::Acquire);
4377            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4378            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4379            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4380            match r {
4381                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4382                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4383            }
4384        }
4385    }
4386
4387    fn refresh_session_journal(&self) {
4388        let Some(file) = self.session_resume_journal() else { return };
4389        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4390        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4391        crate::pty_resume::refresh(&file, &homes);
4392        let ids: Vec<(u64, String)> = {
4393            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4394            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4395        };
4396        for (inst, sid) in ids {
4397            crate::pty_resume::set_session_id(&file, inst, &sid);
4398        }
4399    }
4400
4401    fn session_grok_leader_socket(
4402        &self,
4403        provider: &AgentId,
4404        mode: SessionMode,
4405        instance_id: AgentInstanceId,
4406    ) -> Option<std::path::PathBuf> {
4407        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4408            return None;
4409        }
4410        let root = self
4411            .m4a_accounts
4412            .as_ref()
4413            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4414            .unwrap_or_else(std::env::temp_dir);
4415        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4416        if let Some(previous) = self
4417            .grok_leaders
4418            .lock()
4419            .unwrap_or_else(|p| p.into_inner())
4420            .insert(instance_id, socket.clone())
4421        {
4422            crate::grok_leader::retire(&previous);
4423        }
4424        Some(socket)
4425    }
4426
4427    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4428    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4429        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4430        if let Some(socket) = socket {
4431            crate::grok_leader::retire(&socket);
4432        }
4433    }
4434
4435    fn new_with_incarnation(
4436        handle: Gate4AgentHandle,
4437        access_token: String,
4438        node_id: NodeId,
4439        incarnation_id: NodeIncarnationId,
4440        workspaces: Vec<WorkspaceConfig>,
4441        enabled_providers: Vec<AgentId>,
4442        provider_runtime_statuses: ProviderRuntimeStatuses,
4443        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4444        provider_contracts: Vec<ProviderContractSupport>,
4445        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4446        spawn_profiles: SpawnProfileRegistry,
4447        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4448        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4449        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4450        state_path: Option<PathBuf>,
4451        records: Vec<ManagedSessionRecord>,
4452        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4453        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4454        materializations: Vec<MaterializationOwnershipRecord>,
4455        persistence_warning: Option<String>,
4456        input_settle_timeout_ms: u64,
4457    ) -> Self {
4458        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4459        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4460        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4461        let (harness_mcp_proxy_event_tx, _) =
4462            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4463        let record_providers = records
4464            .iter()
4465            .map(|record| (record.record_id.clone(), record.provider.clone()))
4466            .collect();
4467        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4468            .iter()
4469            .map(|workspace| {
4470                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4471            })
4472            .collect();
4473        for lease in &managed_worktrees {
4474            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4475        }
4476        let managed_worktree_profiles = workspaces.iter()
4477            .map(|workspace| (
4478                workspace.workspace_id.clone(),
4479                workspace.managed_worktree_profiles.clone(),
4480            ))
4481            .collect();
4482        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4483            managed_worktrees,
4484            managed_worktree_tombstones,
4485        ).expect("durable managed worktree registry was validated while loading");
4486        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4487        managed_worktrees.reattach_record_holders(
4488            &managed_worktree_record_holders(&records),
4489            unix_time_ms(),
4490        );
4491        Self {
4492            handle,
4493            access_token,
4494            node_id,
4495            incarnation_id,
4496            started_at_unix_ms: std::time::SystemTime::now()
4497                .duration_since(std::time::UNIX_EPOCH)
4498                .unwrap_or_default()
4499                .as_millis()
4500                .min(u64::MAX as u128) as u64,
4501            workspaces: RwLock::new(
4502                workspaces
4503                    .into_iter()
4504                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4505                    .collect(),
4506            ),
4507            worktree_service_modes,
4508            managed_worktree_profiles,
4509            managed_worktrees: Mutex::new(managed_worktrees),
4510            enabled_providers,
4511            provider_runtime_statuses,
4512            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4513            provider_runtime_monitor,
4514            provider_contracts,
4515            provider_adapter_contracts,
4516            spawn_profiles,
4517            environment_profiles: RwLock::new(BTreeMap::new()),
4518            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4519            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4520            #[cfg(feature = "dig2-station-probe")]
4521            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4522            bundle_catalog: RwLock::new(BundleCatalog::default()),
4523            delivery_store: Mutex::new(None),
4524            context_pack_store: Mutex::new(None),
4525            context_catalog: RwLock::new(ContextPackCatalog::default()),
4526            native_launch_profile_control,
4527            harness_mcp_registry: None,
4528            native_session_catalog,
4529            session_environment_materializer,
4530            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4531            m4a_live: Mutex::new(BTreeMap::new()),
4532            m4a_keys: Mutex::new(BTreeMap::new()),
4533            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4534            grok_leaders: Mutex::new(BTreeMap::new()),
4535            #[cfg(unix)]
4536            #[cfg(unix)]
4537            signer_proxy: std::sync::OnceLock::new(),
4538            materializations: Mutex::new(
4539                materializations
4540                    .into_iter()
4541                    .map(|record| (record.id().clone(), record))
4542                    .collect(),
4543            ),
4544            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4545                entries: BTreeMap::new(),
4546            }),
4547            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4548            controller: Mutex::new(None),
4549            provisional_spawns: Mutex::new(BTreeMap::new()),
4550            pending_acp_options: Mutex::new(BTreeMap::new()),
4551            history: Mutex::new(NodeEventHistory::new(record_providers)),
4552            event_tx,
4553            terminal_event_tx,
4554            agent_stream_event_tx,
4555            harness_mcp_proxy_event_tx,
4556            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4557            pty_input_wake: tokio::sync::Notify::new(),
4558            pty_input_marks: Mutex::new(BTreeMap::new()),
4559            next_connection_id: AtomicU64::new(1),
4560            next_instance_id: AtomicU64::new(1),
4561            next_command_id: AtomicU64::new(1),
4562            shutdown: AtomicBool::new(false),
4563            shutdown_notify: Notify::new(),
4564            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4565            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4566            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4567            mutation_gate: AsyncMutex::new(()),
4568            session_bindings: Mutex::new(BTreeMap::new()),
4569            session_records: Mutex::new(SessionRecords {
4570                records: records
4571                    .into_iter()
4572                    .map(|record| (record.record_id.clone(), record))
4573                    .collect(),
4574            }),
4575            state_path,
4576            persistence_error: RwLock::new(
4577                persistence_warning
4578                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4579            ),
4580            state_transaction: Mutex::new(()),
4581            input_settle_timeout_ms,
4582            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4583            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4584            shell_efficiency_profile: OnceLock::new(),
4585            native_session_gauge: AtomicUsize::new(0),
4586            connection_loop_iterations: AtomicU64::new(0),
4587            connection_events_sent: AtomicU64::new(0),
4588            rejected_commands_total: AtomicU64::new(0),
4589            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4590            drive_loop_iterations_total: AtomicU64::new(0),
4591            drive_loop_iterations_idle: AtomicU64::new(0),
4592            session_record_retention: SessionRecordRetentionConfig::default(),
4593            retired_records_total: AtomicU64::new(0),
4594            #[cfg(feature = "fixture")]
4595            fixture_semantic_hook_policy: false,
4596            #[cfg(feature = "fixture")]
4597            fixture_spawn_managed_worktree_v2_failure_probe:
4598                SpawnManagedWorktreeV2FailureProbe::default(),
4599        }
4600    }
4601
4602    #[cfg(test)]
4603    fn new(
4604        handle: Gate4AgentHandle,
4605        access_token: String,
4606        node_id: NodeId,
4607        workspaces: Vec<WorkspaceConfig>,
4608        enabled_providers: Vec<AgentId>,
4609    ) -> Self {
4610        Self::new_with_incarnation(
4611            handle,
4612            access_token,
4613            node_id,
4614            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4615            workspaces,
4616            enabled_providers,
4617            ProviderRuntimeStatuses::default(),
4618            None,
4619            Vec::new(),
4620            Vec::new(),
4621            SpawnProfileRegistry::default(),
4622            None,
4623            None,
4624            None,
4625            None,
4626            Vec::new(),
4627            Vec::new(),
4628            Vec::new(),
4629            Vec::new(),
4630            None,
4631            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4632        )
4633    }
4634
4635    fn persistence_error(&self) -> Option<String> {
4636        self.persistence_error
4637            .read()
4638            .unwrap_or_else(|poisoned| poisoned.into_inner())
4639            .clone()
4640    }
4641
4642    async fn admit_provider_runtime(
4643        &self,
4644        provider: &AgentId,
4645        requirement: ProviderRuntimeRequirement,
4646    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4647        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4648            let pipe_transport = monitor.supports_pipe_transport(provider);
4649            let acp_transport = monitor.supports_acp_transport(provider);
4650            let provider = provider.clone();
4651            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4652            let (status, admission) = timeout(
4653                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4654                refresh,
4655            )
4656            .await
4657            .map_err(|_| failure(
4658                NodeFailureCode::BackendBusy,
4659                "provider runtime probe exceeded its bounded deadline",
4660            ))?
4661            .map_err(|_| failure(
4662                NodeFailureCode::BackendOperationFailed,
4663                "provider runtime probe task failed",
4664            ))?;
4665            if let Some(status) = status {
4666                self
4667                    .provider_runtime_status_updates
4668                    .write()
4669                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4670                    .insert(status.provider().clone(), status);
4671            }
4672            admission.and_then(|policy| {
4673                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4674            })
4675        } else {
4676            // No live monitor means no catalog to consult -- there is
4677            // nothing here to confirm this provider declares a Pipe or ACP
4678            // transport, so Inline/Acp are rejected the same conservative
4679            // way an undeclared provider always was, rather than admitted
4680            // on trust.
4681            crate::provider_runtime::admit_status(
4682                &self.provider_runtime_statuses,
4683                provider,
4684                false,
4685                false,
4686                requirement,
4687            )
4688        };
4689        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4690        admission.map_err(|error| match error {
4691            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4692                NodeFailureCode::BackendOperationFailed,
4693                "provider launcher is unavailable",
4694            ),
4695            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4696                NodeFailureCode::UnsupportedCapability,
4697                "provider semantic capability is not verified",
4698            ),
4699            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4700                NodeFailureCode::BackendBusy,
4701                "provider runtime probe is already in progress",
4702            ),
4703        })
4704    }
4705
4706    /// Whether the catalog declares a Pipe transport for `provider` --
4707    /// the fact `require_policy` needs to admit or reject
4708    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4709    /// catalog to consult, so this stays conservative and rejects, matching
4710    /// `admit_provider_runtime`'s own no-monitor branch.
4711    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4712        match self.provider_runtime_monitor.as_deref() {
4713            Some(monitor) => monitor.supports_pipe_transport(provider),
4714            None => false,
4715        }
4716    }
4717
4718    /// Whether the catalog declares an ACP transport for `provider` -- the
4719    /// fact `require_policy` needs to admit or reject
4720    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4721    /// `provider_supports_pipe_transport` exactly, including the
4722    /// conservative no-monitor default.
4723    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4724        match self.provider_runtime_monitor.as_deref() {
4725            Some(monitor) => monitor.supports_acp_transport(provider),
4726            None => false,
4727        }
4728    }
4729
4730    /// Grants hook ingestion to a provider whose catalog entry declares a
4731    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4732    ///
4733    /// `admit_status`'s `RawPassthrough` arm hands back
4734    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4735    /// correct for everything it was written to answer, and wrong for hooks
4736    /// specifically. A hook event is not inferred from terminal behaviour,
4737    /// so it has nothing to do with whether a launcher probe verified this
4738    /// CLI version's PTY contract; the authority is the catalog declaring
4739    /// the adapter plus the ingress authenticating the route. Without this
4740    /// the node installs a provider's hooks, the CLI faithfully calls them,
4741    /// and every event is refused -- which is exactly the state grok was in.
4742    ///
4743    /// Applied to both admission paths so the monitor's own derivation and
4744    /// this coarse fallback cannot disagree about the same provider.
4745    fn admit_declared_hook_semantics(
4746        &self,
4747        provider: &AgentId,
4748        policy: ProviderRuntimePolicy,
4749    ) -> ProviderRuntimePolicy {
4750        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4751            return policy;
4752        }
4753        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4754            contract.provider == *provider && contract.family == AdapterFamily::Hook
4755        });
4756        if !declares_hook {
4757            return policy;
4758        }
4759        ProviderRuntimePolicy::new(
4760            policy.raw_pty_lifecycle,
4761            policy.semantic_readiness,
4762            policy.structured_prompt,
4763            policy.provider_session_identity,
4764            policy.semantic_resume,
4765            true,
4766        )
4767        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4768    }
4769
4770    /// Station network allowlist → provider launch overlay argv.
4771    ///
4772    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4773    ///   `provider_native` under Moderate (PTY or ACP).
4774    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4775    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4776    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4777    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4778    ///   no overlay.
4779    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4780    ///   empty overlay when no `provider_native`; invented native keys already
4781    ///   refuse at load / resolve.
4782    ///
4783    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4784    fn resolve_network_allowlist_overlay_args(
4785        &self,
4786        provider: &AgentId,
4787        mode: SessionMode,
4788        approval_level: ApprovalLevel,
4789        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4790        provider_home: Option<&Path>,
4791    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4792        let Some(allowlist_id) = network_allowlist else {
4793            return Ok(Vec::new());
4794        };
4795        let catalog = self
4796            .network_allowlist_catalog
4797            .read()
4798            .unwrap_or_else(|poisoned| poisoned.into_inner());
4799        let Some(entry) = catalog.get(allowlist_id) else {
4800            return Err(failure(
4801                NodeFailureCode::UnknownNetworkAllowlist,
4802                "spawn network allowlist is unavailable on this node",
4803            ));
4804        };
4805
4806        // Claude: settings-shaped overlay from permits (not provider_native).
4807        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4808            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4809                |message| {
4810                    failure(
4811                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812                        &message,
4813                    )
4814                },
4815            )?;
4816            if !matches!(mode, SessionMode::Pty) {
4817                return Err(failure(
4818                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4819                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4820                ));
4821            }
4822            let Some(home) = provider_home else {
4823                return Err(failure(
4824                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4825                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4826                ));
4827            };
4828            let domains =
4829                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4830            if domains.is_empty() {
4831                return Err(failure(
4832                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4833                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4834                ));
4835            }
4836            let settings_path = home.join(
4837                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4838            );
4839            let json =
4840                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4841            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4842                |_| {
4843                    failure(
4844                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4845                        "Claude station network settings sidecar could not be written under ProviderHome",
4846                    )
4847                },
4848            )?;
4849            let overlay =
4850                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4851            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4852        }
4853
4854        let Some(native) = entry.provider_native.as_ref() else {
4855            return Ok(Vec::new());
4856        };
4857        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4858            provider.as_str(),
4859            approval_level,
4860            native,
4861        )
4862        .map_err(|message| {
4863            failure(
4864                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4865                &message,
4866            )
4867        })?;
4868        if overlay.is_empty() {
4869            return Ok(Vec::new());
4870        }
4871        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4872            return Err(failure(
4873                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4874                "Codex network_access -c overlay requires PTY or ACP transport",
4875            ));
4876        }
4877        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4878    }
4879
4880    /// Backward-compatible alias used by Codex-focused unit tests.
4881    fn resolve_codex_network_access_overlay_args(
4882        &self,
4883        provider: &AgentId,
4884        mode: SessionMode,
4885        approval_level: ApprovalLevel,
4886        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4887    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4888        self.resolve_network_allowlist_overlay_args(
4889            provider,
4890            mode,
4891            approval_level,
4892            network_allowlist,
4893            None,
4894        )
4895    }
4896
4897    fn resolve_environment_profile(
4898        &self,
4899        resolved: &ResolvedSpawnSpec,
4900    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4901        // Station network allowlist catalog enforce (plan
4902        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4903        // catalog — unknown ids refuse rather than silent ambient. Runs even
4904        // when no environment_profile_id is present so a bare allowlist still
4905        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4906        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4907            let catalog = self
4908                .network_allowlist_catalog
4909                .read()
4910                .unwrap_or_else(|poisoned| poisoned.into_inner());
4911            let Some(entry) = catalog.get(allowlist_id) else {
4912                return Err(failure(
4913                    NodeFailureCode::UnknownNetworkAllowlist,
4914                    "spawn network allowlist is unavailable on this node",
4915                ));
4916            };
4917            // Track B: refuse provider-native mappings this provider cannot
4918            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4919            if let Some(native) = entry.provider_native.as_ref() {
4920                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4921                    resolved.provider.as_str(),
4922                    resolved.approval_level,
4923                    native,
4924                ) {
4925                    return Err(failure(
4926                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4927                        &message,
4928                    ));
4929                }
4930            }
4931        }
4932        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4933        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4934        // cheap named-pipe path reachability must succeed (Windows
4935        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4936        // NodeFailureCode. Runs even without environment_profile_id (same
4937        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4938        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4939        // acquired later in spawn_session_with_deadline (after instance id).
4940        #[cfg(feature = "dig2-station-probe")]
4941        if resolved.browser_profile_id.is_some() {
4942            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4943            match probe_station_reachable() {
4944                Ok(()) => {}
4945                Err(StationProbeError::UnsupportedPlatform)
4946                | Err(StationProbeError::InvalidPipeSuffix) => {
4947                    return Err(failure(
4948                        NodeFailureCode::BrowserStationProbeUnavailable,
4949                        "dig2browser station probe unavailable on this platform",
4950                    ));
4951                }
4952                Err(StationProbeError::Unreachable) => {
4953                    return Err(failure(
4954                        NodeFailureCode::BrowserStationUnreachable,
4955                        "dig2browser station named-pipe is unreachable",
4956                    ));
4957                }
4958            }
4959        }
4960        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4961            return Ok(None);
4962        };
4963        let profiles = self
4964            .environment_profiles
4965            .read()
4966            .unwrap_or_else(|poisoned| poisoned.into_inner());
4967        let binding = profiles.get(profile_id).ok_or_else(|| {
4968            failure(
4969                NodeFailureCode::UnknownEnvironmentProfile,
4970                "spawn environment profile is unavailable on this node",
4971            )
4972        })?;
4973        if binding.provider != resolved.provider
4974            || binding.native_profile_id(resolved.mode).is_none()
4975        {
4976            return Err(failure(
4977                NodeFailureCode::EnvironmentProfileBindingMismatch,
4978                "spawn environment profile does not match the provider and mode",
4979            ));
4980        }
4981        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4982        // after catalog + optional dig2 probe above. Empty/whitespace ids
4983        // already refuse at Spawn*Id construction.
4984        Ok(Some(ResolvedEnvironmentProfileReceipt {
4985            profile_id: binding.id.clone(),
4986            profile_revision: binding.revision.clone(),
4987            network_allowlist: resolved.network_allowlist.clone(),
4988            browser_profile_id: resolved.browser_profile_id.clone(),
4989        }))
4990    }
4991
4992    fn resolve_bundle(
4993        &self,
4994        resolved: &ResolvedSpawnSpec,
4995        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4996    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4997        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4998            return Ok(None);
4999        };
5000        let catalog = self.bundle_catalog
5001            .read()
5002            .unwrap_or_else(|poisoned| poisoned.into_inner());
5003        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5004                failure(
5005                    NodeFailureCode::UnknownBundle,
5006                    "spawn bundle is unavailable on this node",
5007                )
5008            })?;
5009        self.bundle_layout(
5010            &resolved.provider,
5011            resolved.mode,
5012            environment_profile,
5013            bundle,
5014        )?;
5015        Ok(Some(bundle.receipt()))
5016    }
5017
5018    fn begin_delivery_stage(
5019        &self,
5020        manifest: DeliveryBundleManifestV2,
5021    ) -> Result<NodeResponse, NodeFailure> {
5022        let mut store = self
5023            .delivery_store
5024            .lock()
5025            .unwrap_or_else(|poisoned| poisoned.into_inner());
5026        let store = store.as_mut().ok_or_else(|| {
5027            failure(
5028                NodeFailureCode::DeliveryStageStorageFailed,
5029                "node delivery store is unavailable",
5030            )
5031        })?;
5032        let begun = store.begin(manifest).map_err(delivery_failure)?;
5033        Ok(NodeResponse::DeliveryStageBegun {
5034            stage_id: begun.stage_id,
5035            manifest_digest: begun.manifest_digest,
5036            missing_blobs: begun.missing_blobs,
5037        })
5038    }
5039
5040    fn put_delivery_blob_chunk(
5041        &self,
5042        stage_id: DeliveryStageId,
5043        blob_digest: DeliveryBlobDigestV1,
5044        offset: u64,
5045        chunk_hex: DeliveryBlobChunkHexV1,
5046    ) -> Result<NodeResponse, NodeFailure> {
5047        let mut store = self
5048            .delivery_store
5049            .lock()
5050            .unwrap_or_else(|poisoned| poisoned.into_inner());
5051        let store = store.as_mut().ok_or_else(|| {
5052            failure(
5053                NodeFailureCode::DeliveryStageStorageFailed,
5054                "node delivery store is unavailable",
5055            )
5056        })?;
5057        let accepted = store
5058            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5059            .map_err(delivery_failure)?;
5060        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5061            stage_id: accepted.stage_id,
5062            blob_digest: accepted.blob_digest,
5063            next_offset: accepted.next_offset,
5064        })
5065    }
5066
5067    fn commit_delivery_stage(
5068        &self,
5069        stage_id: DeliveryStageId,
5070    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5071        let mut store = self
5072            .delivery_store
5073            .lock()
5074            .unwrap_or_else(|poisoned| poisoned.into_inner());
5075        let store = store.as_mut().ok_or_else(|| {
5076            failure(
5077                NodeFailureCode::DeliveryStageStorageFailed,
5078                "node delivery store is unavailable",
5079            )
5080        })?;
5081        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5082        let mut catalog = self
5083            .bundle_catalog
5084            .write()
5085            .unwrap_or_else(|poisoned| poisoned.into_inner());
5086        let mut next_catalog = catalog.clone();
5087        next_catalog
5088            .insert_idempotent(prepared.bundle().clone())
5089            .map_err(|_| {
5090                failure(
5091                    NodeFailureCode::DeliveryStageConflict,
5092                    "delivery bundle identity conflicts with installed content",
5093                )
5094            })?;
5095        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5096        *catalog = next_catalog;
5097        Ok(receipt)
5098    }
5099
5100    fn abort_delivery_stage(
5101        &self,
5102        stage_id: &DeliveryStageId,
5103    ) -> Result<(), NodeFailure> {
5104        let mut store = self
5105            .delivery_store
5106            .lock()
5107            .unwrap_or_else(|poisoned| poisoned.into_inner());
5108        let store = store.as_mut().ok_or_else(|| {
5109            failure(
5110                NodeFailureCode::DeliveryStageStorageFailed,
5111                "node delivery store is unavailable",
5112            )
5113        })?;
5114        store.abort(stage_id).map_err(delivery_failure)
5115    }
5116
5117    fn resolve_context(
5118        &self,
5119        resolved: &ResolvedSpawnSpec,
5120    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5121        let Some(context_id) = resolved.context_id.as_ref() else {
5122            return Ok(None);
5123        };
5124        self.context_catalog
5125            .read()
5126            .unwrap_or_else(|poisoned| poisoned.into_inner())
5127            .get(context_id)
5128            .map(|context| context.receipt().clone())
5129            .ok_or_else(|| {
5130                failure(
5131                    NodeFailureCode::UnknownContextPack,
5132                    "spawn context pack is unavailable on this node",
5133                )
5134            })
5135            .map(Some)
5136    }
5137
5138    fn bundle_layout(
5139        &self,
5140        provider: &AgentId,
5141        mode: SessionMode,
5142        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5143        bundle: &NodeBundle,
5144    ) -> Result<BundleProviderLayout, NodeFailure> {
5145        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5146            failure(
5147                NodeFailureCode::BundleBindingMismatch,
5148                "spawn bundle does not match the provider and mode",
5149            )
5150        })?;
5151        if layout != BundleProviderLayout::Codex {
5152            return Ok(layout);
5153        }
5154        let profile = environment_profile.and_then(|receipt| {
5155            let profiles = self
5156                .environment_profiles
5157                .read()
5158                .unwrap_or_else(|poisoned| poisoned.into_inner());
5159            let binding = profiles.get(&receipt.profile_id)?;
5160            if binding.revision != receipt.profile_revision
5161                || &binding.provider != provider
5162                || binding.native_profile_id(mode).is_none()
5163            {
5164                return None;
5165            }
5166            self.environment_materialization_profiles
5167                .read()
5168                .unwrap_or_else(|poisoned| poisoned.into_inner())
5169                .get(&receipt.profile_id)
5170                .cloned()
5171        });
5172        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5173            return Err(failure(
5174                NodeFailureCode::BundleBindingMismatch,
5175                "Codex bundle requires an exact isolated CODEX_HOME profile",
5176            ));
5177        }
5178        Ok(layout)
5179    }
5180
5181    fn select_environment_profile(
5182        &self,
5183        instance_id: AgentInstanceId,
5184        provider: &AgentId,
5185        mode: SessionMode,
5186        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5187    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5188        let Some(environment_profile) = environment_profile else {
5189            return Ok(None);
5190        };
5191        let native_profile_id = {
5192            let profiles = self
5193                .environment_profiles
5194                .read()
5195                .unwrap_or_else(|poisoned| poisoned.into_inner());
5196            let binding = profiles
5197                .get(&environment_profile.profile_id)
5198                .ok_or_else(|| {
5199                    failure(
5200                        NodeFailureCode::UnknownEnvironmentProfile,
5201                        "session environment profile is unavailable on this node",
5202                    )
5203                })?;
5204            if &binding.revision != &environment_profile.profile_revision
5205                || &binding.provider != provider
5206            {
5207                return Err(failure(
5208                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5209                    "session environment profile revision or provider changed",
5210                ));
5211            }
5212            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5213                failure(
5214                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5215                    "session environment profile does not support this mode",
5216                )
5217            })?
5218        };
5219        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5220            failure(
5221                NodeFailureCode::BackendOperationFailed,
5222                "native environment profile controller is unavailable",
5223            )
5224        })?;
5225        control
5226            .select_native_launch_profile(instance_id, native_profile_id)
5227            .map_err(|_| {
5228                failure(
5229                    NodeFailureCode::BackendOperationFailed,
5230                    "native environment profile selection failed",
5231                )
5232            })?;
5233        Ok(Some(NativeEnvironmentSelectionGuard {
5234            control: Some(control),
5235            instance_id,
5236        }))
5237    }
5238
5239    fn install_prepared_launch_overlay(
5240        &self,
5241        instance_id: AgentInstanceId,
5242        overlay: PreparedNativeLaunchOverlay,
5243    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5244        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5245            failure(
5246                NodeFailureCode::BackendOperationFailed,
5247                "native launch profile controller is unavailable",
5248            )
5249        })?;
5250        match overlay {
5251            PreparedNativeLaunchOverlay::Environment(overlay) => {
5252                control
5253                    .install_native_launch_environment_overlay(instance_id, overlay)
5254                    .map_err(|_| failure(
5255                        NodeFailureCode::BackendOperationFailed,
5256                        "session environment overlay installation failed",
5257                    ))?;
5258                Ok(None)
5259            }
5260            PreparedNativeLaunchOverlay::Instance(overlay) => {
5261                control
5262                    .install_native_instance_launch_overlay(instance_id, overlay)
5263                    .map_err(|_| failure(
5264                        NodeFailureCode::BundleBindingMismatch,
5265                        "session bundle launch overlay installation failed",
5266                    ))?;
5267                Ok(Some(NativeInstanceOverlayGuard {
5268                    control: Some(control),
5269                    instance_id,
5270                }))
5271            }
5272        }
5273    }
5274
5275    fn environment_materialization_profile(
5276        &self,
5277        environment_profile: &ResolvedEnvironmentProfileReceipt,
5278    ) -> Option<NodeSessionMaterializationProfile> {
5279        self.environment_materialization_profiles
5280            .read()
5281            .unwrap_or_else(|poisoned| poisoned.into_inner())
5282            .get(&environment_profile.profile_id)
5283            .cloned()
5284    }
5285
5286    fn materialization_profile(
5287        &self,
5288        provider: &AgentId,
5289        mode: SessionMode,
5290        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5291        bundle: Option<&ResolvedBundleReceipt>,
5292        context: Option<&ResolvedContextPackReceipt>,
5293    ) -> Result<Option<(
5294        NodeSessionMaterializationProfile,
5295        bool,
5296        Option<BundleProviderLayout>,
5297    )>, NodeFailure> {
5298        let environment = environment_profile
5299            .and_then(|receipt| self.environment_materialization_profile(receipt));
5300        let (bundle, bundle_layout) = match bundle {
5301            Some(receipt) => {
5302                let catalog = self.bundle_catalog.read()
5303                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5304                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5305                    NodeFailureCode::UnknownBundle,
5306                    "session bundle is unavailable on this node",
5307                ))?;
5308                if installed.receipt() != *receipt {
5309                    return Err(failure(
5310                        NodeFailureCode::BundleBindingMismatch,
5311                        "session bundle revision or digest changed",
5312                    ));
5313                }
5314                let layout = self.bundle_layout(
5315                    provider,
5316                    mode,
5317                    environment_profile,
5318                    installed,
5319                )?;
5320                (Some(installed.clone()), Some(layout))
5321            }
5322            None => (None, None),
5323        };
5324        let has_environment_overlay = environment.is_some() || context.is_some();
5325        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5326            (None, None, None) => None,
5327            (Some(profile), None, None) => Some(Ok(profile)),
5328            (None, Some(bundle), Some(layout)) => {
5329                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5330            }
5331            (Some(profile), Some(bundle), Some(layout)) => {
5332                Some(profile.with_bundle(bundle, layout))
5333            }
5334            _ => {
5335                return Err(failure(
5336                    NodeFailureCode::BundleBindingMismatch,
5337                    "session bundle provider layout is unavailable",
5338                ));
5339            }
5340        };
5341        let profile = profile
5342            .transpose()
5343            .map_err(|_| failure(
5344                NodeFailureCode::BundleMaterializationFailed,
5345                "session bundle materialization profile is invalid",
5346            ))?;
5347        let profile = match (profile, context) {
5348            (Some(profile), Some(receipt)) => {
5349                let catalog = self.context_catalog
5350                    .read()
5351                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5352                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5353                    NodeFailureCode::UnknownContextPack,
5354                    "session context pack is unavailable on this node",
5355                ))?;
5356                if pack.receipt() != receipt {
5357                    return Err(failure(
5358                        NodeFailureCode::ContextPackMaterializationFailed,
5359                        "session context pack identity changed",
5360                    ));
5361                }
5362                profile.with_context(pack)
5363            }
5364            (None, Some(receipt)) => {
5365                let catalog = self.context_catalog
5366                    .read()
5367                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5368                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5369                    NodeFailureCode::UnknownContextPack,
5370                    "session context pack is unavailable on this node",
5371                ))?;
5372                if pack.receipt() != receipt {
5373                    return Err(failure(
5374                        NodeFailureCode::ContextPackMaterializationFailed,
5375                        "session context pack identity changed",
5376                    ));
5377                }
5378                NodeSessionMaterializationProfile::from_context(pack)
5379            }
5380            (Some(profile), None) => Ok(profile),
5381            (None, None) => return Ok(None),
5382        }
5383        .map_err(|_| failure(
5384            NodeFailureCode::ContextPackMaterializationFailed,
5385            "session context pack materialization profile is invalid",
5386        ))?;
5387        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5388    }
5389
5390    fn prepare_session_materialization<'a>(
5391        &'a self,
5392        address: &SessionAddress,
5393        provider: &AgentId,
5394        mode: SessionMode,
5395        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5396        bundle: Option<&ResolvedBundleReceipt>,
5397        context: Option<&ResolvedContextPackReceipt>,
5398        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5399    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5400        let Some((profile, has_environment_overlay, bundle_layout)) =
5401            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5402        else {
5403            return Ok(None);
5404        };
5405        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5406            failure(
5407                NodeFailureCode::BackendOperationFailed,
5408                "session environment materializer is unavailable",
5409            )
5410        })?;
5411        let materialization_failure_code = if context.is_some() {
5412            NodeFailureCode::ContextPackMaterializationFailed
5413        } else if bundle.is_some() {
5414            NodeFailureCode::BundleMaterializationFailed
5415        } else {
5416            NodeFailureCode::BackendOperationFailed
5417        };
5418        let id = MaterializationId::new(format!(
5419            "mat-{}-{}-{}",
5420            self.incarnation_id,
5421            address.session.instance_id.0,
5422            address.session.generation.0,
5423        ))
5424        .map_err(|_| {
5425            failure(
5426                NodeFailureCode::BackendOperationFailed,
5427                "session environment materialization identity failed",
5428            )
5429        })?;
5430        let owner = MaterializationOwner::Session {
5431            incarnation_id: self.incarnation_id,
5432            instance_id: address.session.instance_id,
5433            generation: address.session.generation,
5434        };
5435        let mut ownership = materializer
5436            .begin(
5437                id.clone(),
5438                environment_profile.cloned(),
5439                bundle.cloned(),
5440                context.cloned(),
5441                owner,
5442                managed_lease_id,
5443                &profile,
5444                unix_time_ms(),
5445            )
5446            .map_err(|_| {
5447                failure(
5448                    materialization_failure_code,
5449                    "session materialization preparation failed",
5450                )
5451            })?;
5452        {
5453            let _transaction = self
5454                .state_transaction
5455                .lock()
5456                .unwrap_or_else(|poisoned| poisoned.into_inner());
5457            let duplicate = self
5458                .materializations
5459                .lock()
5460                .unwrap_or_else(|poisoned| poisoned.into_inner())
5461                .insert(id.clone(), ownership.clone());
5462            if duplicate.is_some() {
5463                return Err(failure(
5464                    NodeFailureCode::BackendOperationFailed,
5465                    "session environment materialization identity conflict",
5466                ));
5467            }
5468            if let Err(error) = self.persist_state_locked() {
5469                self.materializations
5470                    .lock()
5471                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5472                    .remove(&id);
5473                return Err(persistence_failure(error));
5474            }
5475        }
5476        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5477            Ok(environment) => environment,
5478            Err(_) => {
5479                let transaction = self
5480                    .state_transaction
5481                    .lock()
5482                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5483                self.materializations
5484                    .lock()
5485                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5486                    .insert(id.clone(), ownership.clone());
5487                if self.persist_state_locked().is_err() {
5488                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5489                }
5490                drop(transaction);
5491                if ownership.state() == MaterializationState::CleanupRequired {
5492                    let _ = self.cleanup_materialization(&id);
5493                }
5494                return Err(failure(
5495                    materialization_failure_code,
5496                    "session materialization failed",
5497                ));
5498            }
5499        };
5500        {
5501            let _transaction = self
5502                .state_transaction
5503                .lock()
5504                .unwrap_or_else(|poisoned| poisoned.into_inner());
5505            self.materializations
5506                .lock()
5507                .unwrap_or_else(|poisoned| poisoned.into_inner())
5508                .insert(id.clone(), ownership.clone());
5509            if let Err(error) = self.persist_state_locked() {
5510                drop(_transaction);
5511                let _ = self.cleanup_materialization(&id);
5512                return Err(persistence_failure(error));
5513            }
5514        }
5515        let guard = SessionMaterializationGuard {
5516            shared: self,
5517            id: Some(id),
5518            provider_home: Some(ownership.provider_home().to_path_buf()),
5519        };
5520        let bundle_arguments = match bundle {
5521            Some(receipt) => {
5522                let catalog = self.bundle_catalog.read()
5523                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5524                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5525                    NodeFailureCode::UnknownBundle,
5526                    "session bundle is unavailable on this node",
5527                ))?;
5528                if installed.receipt() != *receipt {
5529                    return Err(failure(
5530                        NodeFailureCode::BundleBindingMismatch,
5531                        "session bundle revision or digest changed",
5532                    ));
5533                }
5534                let layout = bundle_layout.ok_or_else(|| failure(
5535                    NodeFailureCode::BundleBindingMismatch,
5536                    "materialized bundle provider layout is unavailable",
5537                ))?;
5538                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5539                    failure(
5540                        NodeFailureCode::BundleMaterializationFailed,
5541                        "session bundle byte revalidation failed",
5542                    )
5543                })?;
5544                Some(bundle_launch_arguments(
5545                    layout,
5546                    ownership.bundle_root(),
5547                    ownership.provider_home(),
5548                ).map_err(|_| failure(
5549                    NodeFailureCode::BundleBindingMismatch,
5550                    "session bundle launch binding is invalid",
5551                ))?)
5552            }
5553            None => None,
5554        };
5555        if let Some(receipt) = context {
5556            materializer
5557                .revalidate_context(&ownership, receipt)
5558                .map_err(|_| failure(
5559                    NodeFailureCode::ContextPackMaterializationFailed,
5560                    "session context pack byte revalidation failed",
5561                ))?;
5562        }
5563        let transport = match mode {
5564            SessionMode::Pty => TransportKind::Pty,
5565            SessionMode::Inline => TransportKind::Pipe,
5566            SessionMode::Acp => TransportKind::Acp,
5567        };
5568        let overlay = if let Some(extra_args) = bundle_arguments {
5569            Some(PreparedNativeLaunchOverlay::Instance(
5570                NativeInstanceLaunchOverlay::new(
5571                    provider.clone(),
5572                    transport,
5573                    if has_environment_overlay {
5574                        environment
5575                    } else {
5576                        Vec::new()
5577                    },
5578                    extra_args,
5579                )
5580                .map_err(|_| {
5581                    failure(
5582                        NodeFailureCode::BundleBindingMismatch,
5583                        "session bundle launch overlay is invalid",
5584                    )
5585                })?,
5586            ))
5587        } else if has_environment_overlay {
5588            let context_only = environment_profile.is_none()
5589                && bundle.is_none()
5590                && context.is_some();
5591            let overlay = if context_only {
5592                NativeLaunchEnvironmentOverlay::new_context_root(
5593                    provider.clone(),
5594                    transport,
5595                    environment,
5596                )
5597                .map_err(|_| {
5598                    failure(
5599                        NodeFailureCode::ContextPackMaterializationFailed,
5600                        "context-only launch overlay is invalid",
5601                    )
5602                })?
5603            } else {
5604                NativeLaunchEnvironmentOverlay::new(
5605                    provider.clone(),
5606                    transport,
5607                    environment,
5608                )
5609                .map_err(|_| {
5610                    failure(
5611                        NodeFailureCode::BackendOperationFailed,
5612                        "session environment overlay is invalid",
5613                    )
5614                })?
5615            };
5616            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5617        } else {
5618            None
5619        };
5620        Ok(Some((overlay, guard)))
5621    }
5622
5623    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5624        let Some(mut ownership) = self
5625            .materializations
5626            .lock()
5627            .unwrap_or_else(|poisoned| poisoned.into_inner())
5628            .get(id)
5629            .cloned()
5630        else {
5631            return Ok(());
5632        };
5633        if ownership.state() != MaterializationState::CleanupRequired {
5634            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5635                failure(
5636                    NodeFailureCode::BackendOperationFailed,
5637                    "session environment cleanup state is invalid",
5638                )
5639            })?;
5640        }
5641        {
5642            let _transaction = self
5643                .state_transaction
5644                .lock()
5645                .unwrap_or_else(|poisoned| poisoned.into_inner());
5646            let previous = self
5647                .materializations
5648                .lock()
5649                .unwrap_or_else(|poisoned| poisoned.into_inner())
5650                .insert(id.clone(), ownership.clone());
5651            if let Err(error) = self.persist_state_locked() {
5652                let mut materializations = self
5653                    .materializations
5654                    .lock()
5655                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5656                if let Some(previous) = previous {
5657                    materializations.insert(id.clone(), previous);
5658                }
5659                return Err(persistence_failure(error));
5660            }
5661        }
5662        let cleanup = self
5663            .session_environment_materializer
5664            .as_ref()
5665            .ok_or(())
5666            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5667        if cleanup.is_err() {
5668            let _ = ownership.mark_recovery_required(unix_time_ms());
5669            self.materializations
5670                .lock()
5671                .unwrap_or_else(|poisoned| poisoned.into_inner())
5672                .insert(id.clone(), ownership);
5673            if self.persist_state().is_err() {
5674                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5675            }
5676            return Err(failure(
5677                NodeFailureCode::BackendOperationFailed,
5678                "session environment cleanup requires recovery",
5679            ));
5680        }
5681        let _transaction = self
5682            .state_transaction
5683            .lock()
5684            .unwrap_or_else(|poisoned| poisoned.into_inner());
5685        self.materializations
5686            .lock()
5687            .unwrap_or_else(|poisoned| poisoned.into_inner())
5688            .remove(id);
5689        if let Err(error) = self.persist_state_locked() {
5690            self.materializations
5691                .lock()
5692                .unwrap_or_else(|poisoned| poisoned.into_inner())
5693                .insert(id.clone(), ownership);
5694            return Err(persistence_failure(error));
5695        }
5696        Ok(())
5697    }
5698
5699    fn resolve_record_materialization(
5700        &self,
5701        record_id: &SessionRecordId,
5702        provider: &AgentId,
5703        mode: SessionMode,
5704        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5705        bundle: Option<&ResolvedBundleReceipt>,
5706        context: Option<&ResolvedContextPackReceipt>,
5707    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5708        let ownership = {
5709            let materializations = self
5710                .materializations
5711                .lock()
5712                .unwrap_or_else(|poisoned| poisoned.into_inner());
5713            let mut matches = materializations.values().filter(|record| {
5714                record.owner()
5715                    == &MaterializationOwner::Record {
5716                        record_id: record_id.clone(),
5717                    }
5718            });
5719            let ownership = matches.next().cloned();
5720            if matches.next().is_some() {
5721                return Err(failure(
5722                    NodeFailureCode::BackendOperationFailed,
5723                    "managed session has conflicting environment ownership",
5724                ));
5725            }
5726            ownership
5727        };
5728        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5729            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5730                failure(
5731                    NodeFailureCode::ContextPackMaterializationFailed,
5732                    "session context materializer is unavailable",
5733                )
5734            })?;
5735            let pack = materializer
5736                .revalidate_context(ownership, receipt)
5737                .map_err(|_| {
5738                    self.mark_materialization_recovery_required(ownership.id());
5739                    failure(
5740                        NodeFailureCode::ContextPackMaterializationFailed,
5741                        "managed session context pack revalidation failed",
5742                    )
5743                })?;
5744            self.context_catalog
5745                .write()
5746                .unwrap_or_else(|poisoned| poisoned.into_inner())
5747                .insert(pack)
5748                .map_err(|_| failure(
5749                    NodeFailureCode::ContextPackMaterializationFailed,
5750                    "managed session context pack catalog restore failed",
5751                ))?;
5752        }
5753        let materialization = self.materialization_profile(
5754            provider,
5755            mode,
5756            environment_profile,
5757            bundle,
5758            context,
5759        )?;
5760        match (ownership, materialization) {
5761            (None, None) => Ok(None),
5762            (None, Some(_)) | (Some(_), None) => Err(failure(
5763                NodeFailureCode::BackendOperationFailed,
5764                "managed session materialization is unavailable",
5765            )),
5766            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5767                if ownership.environment_profile() != environment_profile
5768                    || ownership.bundle() != bundle
5769                    || ownership.context() != context
5770                    || ownership.state() != MaterializationState::Ready
5771                {
5772                    self.mark_materialization_recovery_required(ownership.id());
5773                    return Err(failure(
5774                        NodeFailureCode::BackendOperationFailed,
5775                        "managed session environment ownership requires recovery",
5776                    ));
5777                }
5778                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5779                    failure(
5780                        NodeFailureCode::BackendOperationFailed,
5781                        "session environment materializer is unavailable",
5782                    )
5783                })?;
5784                if materializer.revalidate(&ownership).is_err() {
5785                    self.mark_materialization_recovery_required(ownership.id());
5786                    return Err(failure(
5787                        NodeFailureCode::BackendOperationFailed,
5788                        "managed session environment revalidation failed",
5789                    ));
5790                }
5791                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5792                    let catalog = self.bundle_catalog.read()
5793                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5794                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5795                        return Err(failure(
5796                            NodeFailureCode::UnknownBundle,
5797                            "managed session bundle is unavailable",
5798                        ));
5799                    };
5800                    let layout = bundle_layout.ok_or_else(|| failure(
5801                        NodeFailureCode::BundleBindingMismatch,
5802                        "record bundle provider layout is unavailable",
5803                    ))?;
5804                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5805                        drop(catalog);
5806                        self.mark_materialization_recovery_required(ownership.id());
5807                        return Err(failure(
5808                            NodeFailureCode::BundleMaterializationFailed,
5809                            "managed session bundle revalidation failed",
5810                        ));
5811                    }
5812                    Some(bundle_launch_arguments(
5813                        layout,
5814                        ownership.bundle_root(),
5815                        ownership.provider_home(),
5816                    ).map_err(|_| failure(
5817                        NodeFailureCode::BundleBindingMismatch,
5818                        "managed session bundle launch binding is invalid",
5819                    ))?)
5820                } else {
5821                    None
5822                };
5823                if !has_environment_materialization {
5824                    let overlay = bundle_arguments.map(|extra_args| {
5825                        NativeInstanceLaunchOverlay::new(
5826                            provider.clone(),
5827                            TransportKind::Pty,
5828                            Vec::new(),
5829                            extra_args,
5830                        )
5831                        .map(PreparedNativeLaunchOverlay::Instance)
5832                        .map_err(|_| failure(
5833                            NodeFailureCode::BundleBindingMismatch,
5834                            "managed session bundle launch overlay is invalid",
5835                        ))
5836                    }).transpose()?;
5837                    return Ok(Some((ownership.id().clone(), overlay)));
5838                }
5839                let environment = match materializer.resolve_environment(&ownership, &profile) {
5840                    Ok(environment) => environment,
5841                    Err(
5842                        SessionEnvironmentMaterializeError::SecretUnavailable
5843                        | SessionEnvironmentMaterializeError::SecretDenied
5844                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5845                    ) => {
5846                        return Err(failure(
5847                            NodeFailureCode::BackendOperationFailed,
5848                            "managed session environment resolution failed",
5849                        ));
5850                    }
5851                    Err(
5852                        SessionEnvironmentMaterializeError::InvalidRoot
5853                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5854                        | SessionEnvironmentMaterializeError::Filesystem(_)
5855                        | SessionEnvironmentMaterializeError::Record(_),
5856                    ) => {
5857                        self.mark_materialization_recovery_required(ownership.id());
5858                        return Err(failure(
5859                            NodeFailureCode::BackendOperationFailed,
5860                            "managed session environment resolution failed",
5861                        ));
5862                    }
5863                };
5864                let transport = match mode {
5865                    SessionMode::Pty => TransportKind::Pty,
5866                    SessionMode::Inline => TransportKind::Pipe,
5867                    SessionMode::Acp => TransportKind::Acp,
5868                };
5869                let overlay = match bundle_arguments {
5870                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5871                        NativeInstanceLaunchOverlay::new(
5872                            provider.clone(),
5873                            transport,
5874                            environment,
5875                            extra_args,
5876                        )
5877                        .map_err(|_| failure(
5878                            NodeFailureCode::BundleBindingMismatch,
5879                            "managed session bundle launch overlay is invalid",
5880                        ))?,
5881                    ),
5882                    None => PreparedNativeLaunchOverlay::Environment(
5883                        NativeLaunchEnvironmentOverlay::new(
5884                            provider.clone(),
5885                            transport,
5886                            environment,
5887                        )
5888                        .map_err(|_| {
5889                            failure(
5890                                NodeFailureCode::BackendOperationFailed,
5891                                "managed session environment overlay is invalid",
5892                            )
5893                        })?,
5894                    ),
5895                };
5896                Ok(Some((ownership.id().clone(), Some(overlay))))
5897            }
5898        }
5899    }
5900
5901    fn destroy_record_materialization(
5902        &self,
5903        record_id: &SessionRecordId,
5904    ) -> Result<bool, NodeFailure> {
5905        let ids = self
5906            .materializations
5907            .lock()
5908            .unwrap_or_else(|poisoned| poisoned.into_inner())
5909            .values()
5910            .filter_map(|record| {
5911                (record.owner()
5912                    == &MaterializationOwner::Record {
5913                        record_id: record_id.clone(),
5914                    })
5915                .then(|| record.id().clone())
5916            })
5917            .collect::<Vec<_>>();
5918        if ids.len() > 1 {
5919            return Err(failure(
5920                NodeFailureCode::BackendOperationFailed,
5921                "managed session has conflicting environment ownership",
5922            ));
5923        }
5924        let Some(id) = ids.first().cloned() else {
5925            return Ok(false);
5926        };
5927        let (terminal_record, mut cleanup_ownership) = {
5928            let _transaction = self
5929                .state_transaction
5930                .lock()
5931                .unwrap_or_else(|poisoned| poisoned.into_inner());
5932            let original_record = self.record(record_id)?;
5933            let mut terminal_record = original_record.clone();
5934            terminal_record.provider_session = None;
5935            terminal_record.active_session = None;
5936            terminal_record.state = ManagedSessionState::Unavailable;
5937            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5938            terminal_record.updated_at_unix_ms = unix_time_ms();
5939            let original_ownership = self
5940                .materializations
5941                .lock()
5942                .unwrap_or_else(|poisoned| poisoned.into_inner())
5943                .get(&id)
5944                .cloned()
5945                .ok_or_else(|| {
5946                    failure(
5947                        NodeFailureCode::BackendOperationFailed,
5948                        "managed session environment ownership disappeared during cleanup",
5949                    )
5950                })?;
5951            let mut cleanup_ownership = original_ownership.clone();
5952            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5953                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5954                    failure(
5955                        NodeFailureCode::BackendOperationFailed,
5956                        "managed session environment cleanup state is invalid",
5957                    )
5958                })?;
5959            }
5960            self.session_records
5961                .lock()
5962                .unwrap_or_else(|poisoned| poisoned.into_inner())
5963                .records
5964                .insert(record_id.clone(), terminal_record.clone());
5965            self.materializations
5966                .lock()
5967                .unwrap_or_else(|poisoned| poisoned.into_inner())
5968                .insert(id.clone(), cleanup_ownership.clone());
5969            if let Err(error) = self.persist_state_locked() {
5970                self.session_records
5971                    .lock()
5972                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5973                    .records
5974                    .insert(record_id.clone(), original_record);
5975                self.materializations
5976                    .lock()
5977                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5978                    .insert(id.clone(), original_ownership);
5979                return Err(persistence_failure(error));
5980            }
5981            (terminal_record, cleanup_ownership)
5982        };
5983        let cleanup = self
5984            .session_environment_materializer
5985            .as_ref()
5986            .ok_or(())
5987            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5988        if cleanup.is_err() {
5989            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5990            self.materializations
5991                .lock()
5992                .unwrap_or_else(|poisoned| poisoned.into_inner())
5993                .insert(id.clone(), cleanup_ownership);
5994            if self.persist_state().is_err() {
5995                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5996            }
5997            return Err(failure(
5998                NodeFailureCode::BackendOperationFailed,
5999                "managed session environment cleanup requires recovery",
6000            ));
6001        }
6002        let _transaction = self
6003            .state_transaction
6004            .lock()
6005            .unwrap_or_else(|poisoned| poisoned.into_inner());
6006        self.remove_record_memory(record_id);
6007        self.materializations
6008            .lock()
6009            .unwrap_or_else(|poisoned| poisoned.into_inner())
6010            .remove(&id);
6011        if let Err(error) = self.persist_state_locked() {
6012            self.session_records
6013                .lock()
6014                .unwrap_or_else(|poisoned| poisoned.into_inner())
6015                .records
6016                .insert(record_id.clone(), terminal_record);
6017            self.materializations
6018                .lock()
6019                .unwrap_or_else(|poisoned| poisoned.into_inner())
6020                .insert(id, cleanup_ownership);
6021            return Err(persistence_failure(error));
6022        }
6023        Ok(true)
6024    }
6025
6026    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6027        let changed = {
6028            let mut materializations = self
6029                .materializations
6030                .lock()
6031                .unwrap_or_else(|poisoned| poisoned.into_inner());
6032            let Some(ownership) = materializations.get_mut(id) else {
6033                return;
6034            };
6035            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6036        };
6037        if changed && self.persist_state().is_err() {
6038            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6039        }
6040    }
6041
6042    fn reconcile_materializations(&self) {
6043        let records = self
6044            .materializations
6045            .lock()
6046            .unwrap_or_else(|poisoned| poisoned.into_inner())
6047            .values()
6048            .cloned()
6049            .collect::<Vec<_>>();
6050        for mut ownership in records {
6051            let result = match (ownership.state(), ownership.owner()) {
6052                (MaterializationState::Preparing, _)
6053                | (MaterializationState::CleanupRequired, _) => {
6054                    self.cleanup_materialization(ownership.id())
6055                }
6056                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6057                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6058                        self.materializations
6059                            .lock()
6060                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6061                            .insert(ownership.id().clone(), ownership.clone());
6062                        self.persist_state().map_err(persistence_failure)
6063                    } else {
6064                        Err(failure(
6065                            NodeFailureCode::BackendOperationFailed,
6066                            "session environment reconciliation failed",
6067                        ))
6068                    }
6069                }
6070                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6071                    .session_environment_materializer
6072                    .as_ref()
6073                    .ok_or_else(|| {
6074                        failure(
6075                            NodeFailureCode::BackendOperationFailed,
6076                            "session environment materializer is unavailable",
6077                        )
6078                    })
6079                    .and_then(|materializer| {
6080                        materializer.revalidate(&ownership).map_err(|_| failure(
6081                            NodeFailureCode::BackendOperationFailed,
6082                            "session materialization revalidation failed",
6083                        ))?;
6084                        let record = self.record(record_id)?;
6085                        if record.environment_profile.as_ref() != ownership.environment_profile()
6086                            || record.bundle.as_ref() != ownership.bundle()
6087                            || record.context.as_ref() != ownership.context()
6088                        {
6089                            return Err(failure(
6090                                NodeFailureCode::BackendOperationFailed,
6091                                "session materialization ownership changed after restart",
6092                            ));
6093                        }
6094                        if let Some(receipt) = ownership.bundle() {
6095                            let catalog = self.bundle_catalog.read()
6096                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6097                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6098                                NodeFailureCode::UnknownBundle,
6099                                "session bundle is unavailable after restart",
6100                            ))?;
6101                            if bundle.receipt() != *receipt {
6102                                return Err(failure(
6103                                    NodeFailureCode::BundleBindingMismatch,
6104                                    "session bundle changed after restart",
6105                                ));
6106                            }
6107                            let layout = self.bundle_layout(
6108                                &record.provider,
6109                                record.mode,
6110                                record.environment_profile.as_ref(),
6111                                bundle,
6112                            )?;
6113                            materializer
6114                                .revalidate_bundle(&ownership, bundle, layout)
6115                                .map_err(|_| failure(
6116                                    NodeFailureCode::BundleMaterializationFailed,
6117                                    "session bundle revalidation failed",
6118                                ))?;
6119                        }
6120                        if let Some(receipt) = ownership.context() {
6121                            let pack = materializer
6122                                .revalidate_context(&ownership, receipt)
6123                                .map_err(|_| {
6124                                    failure(
6125                                        NodeFailureCode::ContextPackMaterializationFailed,
6126                                        "session context revalidation failed",
6127                                    )
6128                                })?;
6129                            self.context_catalog
6130                                .write()
6131                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6132                                .insert(pack)
6133                                .map_err(|_| {
6134                                    failure(
6135                                        NodeFailureCode::ContextPackMaterializationFailed,
6136                                        "session context catalog restore failed",
6137                                    )
6138                                })?;
6139                        }
6140                        Ok(())
6141                    }),
6142                (MaterializationState::RecoveryRequired, _) => Ok(()),
6143            };
6144            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6145                let _ = ownership.mark_recovery_required(unix_time_ms());
6146                self.materializations
6147                    .lock()
6148                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6149                    .insert(ownership.id().clone(), ownership);
6150                if self.persist_state().is_err() {
6151                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6152                }
6153            }
6154        }
6155        let materialized_profiles = self
6156            .environment_materialization_profiles
6157            .read()
6158            .unwrap_or_else(|poisoned| poisoned.into_inner())
6159            .keys()
6160            .cloned()
6161            .collect::<Vec<_>>();
6162        let ownership = self
6163            .materializations
6164            .lock()
6165            .unwrap_or_else(|poisoned| poisoned.into_inner())
6166            .values()
6167            .cloned()
6168            .collect::<Vec<_>>();
6169        let changed = {
6170            let mut session_records = self
6171                .session_records
6172                .lock()
6173                .unwrap_or_else(|poisoned| poisoned.into_inner());
6174            let mut changed = false;
6175            for record in session_records.records.values_mut() {
6176                let environment_profile = record.environment_profile.as_ref();
6177                if environment_profile.is_none()
6178                    && record.bundle.is_none()
6179                    && record.context.is_none()
6180                {
6181                    continue;
6182                }
6183                if record.bundle.is_none()
6184                    && environment_profile.is_some_and(|profile| {
6185                        !materialized_profiles.contains(&profile.profile_id)
6186                    })
6187                {
6188                    continue;
6189                }
6190                let has_exact_ownership = ownership.iter().any(|candidate| {
6191                    candidate.state() == MaterializationState::Ready
6192                        && candidate.environment_profile() == environment_profile
6193                        && candidate.bundle() == record.bundle.as_ref()
6194                        && candidate.context() == record.context.as_ref()
6195                        && candidate.owner()
6196                            == &MaterializationOwner::Record {
6197                                record_id: record.record_id.clone(),
6198                            }
6199                });
6200                if !has_exact_ownership {
6201                    record.provider_session = None;
6202                    record.active_session = None;
6203                    record.state = ManagedSessionState::Unavailable;
6204                    record.last_error = Some(if record.bundle.is_some() {
6205                        "bundle-unavailable".to_owned()
6206                    } else if record.context.is_some() {
6207                        "context-unavailable".to_owned()
6208                    } else {
6209                        "environment-profile-unavailable".to_owned()
6210                    });
6211                    record.updated_at_unix_ms = unix_time_ms();
6212                    changed = true;
6213                }
6214            }
6215            changed
6216        };
6217        if changed && self.persist_state().is_err() {
6218            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6219        }
6220    }
6221
6222    fn replay_spawn_spec(
6223        &self,
6224        spec: &SpawnSpec,
6225        now: Instant,
6226    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6227        let mut cache = self
6228            .spawn_idempotency
6229            .lock()
6230            .unwrap_or_else(|poisoned| poisoned.into_inner());
6231        cache.entries.retain(|_, entry| entry.expires_at > now);
6232        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6233            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6234                return Err(failure(
6235                    NodeFailureCode::SpawnIdempotencyConflict,
6236                    "spawn idempotency key was reused across spawn kinds",
6237                ));
6238            };
6239            if existing != spec {
6240                return Err(failure(
6241                    NodeFailureCode::SpawnIdempotencyConflict,
6242                    "spawn idempotency key was reused with a different specification",
6243                ));
6244            }
6245            return Ok(Some(result.clone()));
6246        }
6247        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6248            return Err(failure(
6249                NodeFailureCode::SpawnIdempotencyCapacity,
6250                "spawn idempotency cache reached its bounded capacity",
6251            ));
6252        }
6253        Ok(None)
6254    }
6255
6256    fn remember_spawn_spec(
6257        &self,
6258        spec: SpawnSpec,
6259        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6260        accepted_at: Instant,
6261    ) {
6262        self
6263            .spawn_idempotency
6264            .lock()
6265            .unwrap_or_else(|poisoned| poisoned.into_inner())
6266            .entries
6267            .insert(
6268                spec.idempotency_key.clone(),
6269                SpawnIdempotencyEntry {
6270                    value: SpawnIdempotencyValue::Standard { spec, result },
6271                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6272                },
6273            );
6274    }
6275
6276    fn claim_harness_mcp_spawn(
6277        &self,
6278        _reservation_id: &HarnessMcpReservationId,
6279        _activation_digest: &HarnessMcpActivationDigest,
6280        spec: &SpawnSpec,
6281        now: Instant,
6282    ) -> Result<(), NodeFailure> {
6283        let mut cache = self.spawn_idempotency.lock()
6284            .unwrap_or_else(|poisoned| poisoned.into_inner());
6285        cache.entries.retain(|_, entry| entry.expires_at > now);
6286        if cache.entries.contains_key(&spec.idempotency_key) {
6287            return Err(failure(
6288                NodeFailureCode::SpawnIdempotencyConflict,
6289                "spawn idempotency key was reused across spawn kinds",
6290            ));
6291        }
6292        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6293            return Err(failure(
6294                NodeFailureCode::SpawnIdempotencyCapacity,
6295                "spawn idempotency cache reached its bounded capacity",
6296            ));
6297        }
6298        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6299            value: SpawnIdempotencyValue::HarnessMcp,
6300            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6301        });
6302        Ok(())
6303    }
6304
6305    fn replay_managed_spawn(
6306        &self,
6307        request: &ManagedWorktreeSpawnRequest,
6308        now: Instant,
6309    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6310        let mut cache = self.spawn_idempotency.lock()
6311            .unwrap_or_else(|poisoned| poisoned.into_inner());
6312        cache.entries.retain(|_, entry| entry.expires_at > now);
6313        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6314            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6315                return Err(failure(
6316                    NodeFailureCode::SpawnIdempotencyConflict,
6317                    "spawn idempotency key was reused across spawn kinds",
6318                ));
6319            };
6320            if existing != request {
6321                return Err(failure(
6322                    NodeFailureCode::SpawnIdempotencyConflict,
6323                    "spawn idempotency key was reused with a different managed specification",
6324                ));
6325            }
6326            return Ok(Some(result.clone()));
6327        }
6328        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6329            return Err(failure(
6330                NodeFailureCode::SpawnIdempotencyCapacity,
6331                "spawn idempotency cache reached its bounded capacity",
6332            ));
6333        }
6334        Ok(None)
6335    }
6336
6337    fn remember_managed_spawn(
6338        &self,
6339        request: ManagedWorktreeSpawnRequest,
6340        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6341        accepted_at: Instant,
6342    ) {
6343        self.spawn_idempotency.lock()
6344            .unwrap_or_else(|poisoned| poisoned.into_inner())
6345            .entries
6346            .insert(
6347                request.spawn_spec.idempotency_key.clone(),
6348                SpawnIdempotencyEntry {
6349                    value: SpawnIdempotencyValue::Managed { request, result },
6350                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6351                },
6352            );
6353    }
6354
6355    fn remember_managed_spawn_attempt(
6356        &self,
6357        durable_v2_key: Option<&SpawnIdempotencyKey>,
6358        request: ManagedWorktreeSpawnRequest,
6359        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6360        accepted_at: Instant,
6361    ) {
6362        if durable_v2_key.is_none() {
6363            self.remember_managed_spawn(request, result, accepted_at);
6364        }
6365    }
6366
6367    fn acquire_durable_managed_spawn_v2(
6368        &self,
6369        request: &ManagedWorktreeSpawnRequestV2,
6370        request_digest: String,
6371    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6372        if self.state_path.is_none() {
6373            return Err(failure(
6374                NodeFailureCode::BackendOperationFailed,
6375                "managed worktree V2 requires durable node state",
6376            ));
6377        }
6378        let _transaction = self.state_transaction
6379            .lock()
6380            .unwrap_or_else(|poisoned| poisoned.into_inner());
6381        let key = request.spawn_spec.idempotency_key.clone();
6382        let mut previous = None;
6383        {
6384            let mut replays = self.managed_spawn_replays
6385                .lock()
6386                .unwrap_or_else(|poisoned| poisoned.into_inner());
6387            if let Some(record) = replays.get_mut(&key) {
6388                if record.request_digest != request_digest {
6389                    return Err(failure(
6390                        NodeFailureCode::SpawnIdempotencyConflict,
6391                        "managed worktree V2 idempotency key was reused with a different request",
6392                    ));
6393                }
6394                match &record.state {
6395                    ManagedWorktreeSpawnReplayStateV10::Pending {
6396                        owner_incarnation_id,
6397                        lease_id: None,
6398                    } if owner_incarnation_id != &self.incarnation_id => {
6399                        previous = Some(record.clone());
6400                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6401                            owner_incarnation_id: self.incarnation_id,
6402                            lease_id: None,
6403                        };
6404                        record.updated_at_unix_ms = unix_time_ms();
6405                    }
6406                    ManagedWorktreeSpawnReplayStateV10::Pending {
6407                        lease_id: None,
6408                        ..
6409                    } => {
6410                        return Err(failure(
6411                            NodeFailureCode::BackendBusy,
6412                            "managed worktree V2 reservation is already active",
6413                        ));
6414                    }
6415                    ManagedWorktreeSpawnReplayStateV10::Pending {
6416                        lease_id: Some(_),
6417                        ..
6418                    } => {
6419                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6420                    }
6421                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6422                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6423                            receipt.clone(),
6424                        ));
6425                    }
6426                }
6427            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6428                return Err(failure(
6429                    NodeFailureCode::SpawnIdempotencyCapacity,
6430                    "durable managed worktree replay ledger reached its bounded capacity",
6431                ));
6432            } else {
6433                let now = unix_time_ms();
6434                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6435                    idempotency_key: key.clone(),
6436                    request_digest,
6437                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6438                    profile_id: request.worktree_profile_id.clone(),
6439                    expected_profile_revision: request.expected_profile_revision.clone(),
6440                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6441                        owner_incarnation_id: self.incarnation_id,
6442                        lease_id: None,
6443                    },
6444                    created_at_unix_ms: now,
6445                    updated_at_unix_ms: now,
6446                });
6447            }
6448        }
6449        if let Err(error) = self.persist_state_locked() {
6450            let mut replays = self.managed_spawn_replays
6451                .lock()
6452                .unwrap_or_else(|poisoned| poisoned.into_inner());
6453            match previous {
6454                Some(previous) => {
6455                    replays.insert(key, previous);
6456                }
6457                None => {
6458                    replays.remove(&key);
6459                }
6460            }
6461            return Err(persistence_failure(error));
6462        }
6463        Ok(DurableManagedSpawnReplayDecision::Reserved)
6464    }
6465
6466    fn link_durable_managed_spawn_v2_lease_locked(
6467        &self,
6468        key: &SpawnIdempotencyKey,
6469        lease_id: &ManagedWorktreeLeaseId,
6470    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6471        let mut replays = self.managed_spawn_replays
6472            .lock()
6473            .unwrap_or_else(|poisoned| poisoned.into_inner());
6474        let record = replays.get_mut(key).ok_or_else(|| {
6475            failure(
6476                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6477                "managed worktree V2 reservation disappeared before allocation",
6478            )
6479        })?;
6480        let previous = record.clone();
6481        match &mut record.state {
6482            ManagedWorktreeSpawnReplayStateV10::Pending {
6483                lease_id: current,
6484                ..
6485            }
6486                if current.is_none() =>
6487            {
6488                *current = Some(lease_id.clone());
6489                record.updated_at_unix_ms = unix_time_ms();
6490                Ok(previous)
6491            }
6492            _ => Err(failure(
6493                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6494                "managed worktree V2 reservation is not allocation-ready",
6495            )),
6496        }
6497    }
6498
6499    fn restore_durable_managed_spawn_v2_locked(
6500        &self,
6501        key: &SpawnIdempotencyKey,
6502        previous: ManagedWorktreeSpawnReplayRecordV10,
6503    ) {
6504        self.managed_spawn_replays
6505            .lock()
6506            .unwrap_or_else(|poisoned| poisoned.into_inner())
6507            .insert(key.clone(), previous);
6508    }
6509
6510    fn commit_durable_managed_spawn_v2(
6511        &self,
6512        request: &ManagedWorktreeSpawnRequestV2,
6513        receipt: ManagedWorktreeSpawnReceipt,
6514    ) -> Result<(), NodeFailure> {
6515        let _transaction = self.state_transaction
6516            .lock()
6517            .unwrap_or_else(|poisoned| poisoned.into_inner());
6518        let key = &request.spawn_spec.idempotency_key;
6519        let previous = {
6520            let mut replays = self.managed_spawn_replays
6521                .lock()
6522                .unwrap_or_else(|poisoned| poisoned.into_inner());
6523            let record = replays.get_mut(key).ok_or_else(|| {
6524                failure(
6525                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6526                    "managed worktree V2 reservation disappeared before commit",
6527                )
6528            })?;
6529            let previous = record.clone();
6530            match &record.state {
6531                ManagedWorktreeSpawnReplayStateV10::Pending {
6532                    lease_id: Some(lease_id),
6533                    ..
6534                } if lease_id == &receipt.lease.lease_id => {}
6535                _ => {
6536                    return Err(failure(
6537                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6538                        "managed worktree V2 reservation is not linked to the spawned lease",
6539                    ));
6540                }
6541            }
6542            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6543            record.updated_at_unix_ms = unix_time_ms();
6544            previous
6545        };
6546        if let Err(error) = self.persist_state_locked() {
6547            self.restore_durable_managed_spawn_v2_locked(key, previous);
6548            return Err(persistence_failure(error));
6549        }
6550        Ok(())
6551    }
6552
6553    fn remove_unallocated_durable_managed_spawn_v2(
6554        &self,
6555        request: &ManagedWorktreeSpawnRequestV2,
6556    ) -> Result<(), NodeFailure> {
6557        let _transaction = self.state_transaction
6558            .lock()
6559            .unwrap_or_else(|poisoned| poisoned.into_inner());
6560        let key = &request.spawn_spec.idempotency_key;
6561        let removed = {
6562            let mut replays = self.managed_spawn_replays
6563                .lock()
6564                .unwrap_or_else(|poisoned| poisoned.into_inner());
6565            match replays.get(key).map(|record| &record.state) {
6566                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6567                    lease_id: None,
6568                    ..
6569                }) => {
6570                    replays.remove(key)
6571                }
6572                _ => None,
6573            }
6574        };
6575        let Some(removed) = removed else {
6576            return Ok(());
6577        };
6578        if let Err(error) = self.persist_state_locked() {
6579            self.managed_spawn_replays
6580                .lock()
6581                .unwrap_or_else(|poisoned| poisoned.into_inner())
6582                .insert(key.clone(), removed);
6583            return Err(persistence_failure(error));
6584        }
6585        Ok(())
6586    }
6587
6588    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6589        if spec.target.node_id != self.node_id {
6590            return Err(failure(
6591                NodeFailureCode::SpawnTargetMismatch,
6592                "spawn target does not match this node",
6593            ));
6594        }
6595        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6596            failure(
6597                NodeFailureCode::UnknownSpawnProfile,
6598                "spawn profile is unavailable on this node",
6599            )
6600        })?;
6601        let resolved = spec.resolve(defaults).map_err(|error| match error {
6602            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6603                NodeFailureCode::SpawnProfileRevisionMismatch,
6604                "spawn profile revision does not match the loaded profile",
6605            ),
6606            _ => failure(
6607                NodeFailureCode::InvalidRequest,
6608                "spawn specification could not be resolved",
6609            ),
6610        })?;
6611        if resolved.target.worktree_id.is_some() {
6612            self.require_worktree_service(&resolved.target.workspace_id)?;
6613        }
6614        let environment_profile = self.resolve_environment_profile(&resolved)?;
6615        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6616        self.resolve_context(&resolved)?;
6617        Ok(resolved)
6618    }
6619
6620    async fn resolve_spawn_workspace(
6621        &self,
6622        resolved: &ResolvedSpawnSpec,
6623        deadline: Instant,
6624    ) -> Result<WorkspaceId, NodeFailure> {
6625        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6626            return Ok(resolved.target.workspace_id.clone());
6627        };
6628        if self.managed_worktrees.lock()
6629            .unwrap_or_else(|poisoned| poisoned.into_inner())
6630            .lease_for_workspace(worktree_id)
6631            .is_some()
6632        {
6633            return Err(failure(
6634                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6635                "managed worktree targets may only be spawned through their lease request",
6636            ));
6637        }
6638        self.require_worktree_service(&resolved.target.workspace_id)?;
6639        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6640        let selected_root = self.workspace_root(worktree_id)?;
6641        let remaining = spawn_deadline_remaining(deadline)?;
6642        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6643            .await
6644            .map_err(|_| {
6645                failure(
6646                    NodeFailureCode::SpawnDeadlineExceeded,
6647                    "Git worktree selection exceeded the spawn deadline",
6648                )
6649            })?
6650            .map_err(git_worktree_failure)?;
6651        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6652        Ok(worktree_id.clone())
6653    }
6654
6655    async fn spawn_from_spec(
6656        &self,
6657        spec: SpawnSpec,
6658    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6659        self.spawn_from_spec_with_proxy(spec, None).await
6660    }
6661
6662    async fn spawn_from_spec_with_proxy(
6663        &self,
6664        spec: SpawnSpec,
6665        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6666    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6667        let accepted_at = Instant::now();
6668        if harness_mcp.is_none() {
6669            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6670                return replayed;
6671            }
6672        }
6673        let resolved = self.resolve_spawn_spec(&spec)?;
6674        let environment_profile = self.resolve_environment_profile(&resolved)?;
6675        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6676        let context = self.resolve_context(&resolved)?;
6677        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6678        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6679        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6680        let spawn_workspace_id = match spawn_workspace_id {
6681            Ok(workspace_id) => workspace_id,
6682            Err(error) => {
6683                let result = Err(error);
6684                if harness_mcp.is_none() {
6685                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6686                }
6687                return result;
6688            }
6689        };
6690        if self.managed_worktrees.lock()
6691            .unwrap_or_else(|poisoned| poisoned.into_inner())
6692            .lease_for_workspace(&spawn_workspace_id)
6693            .is_some()
6694        {
6695            let result = Err(failure(
6696                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6697                "managed worktree targets may only be spawned through their lease request",
6698            ));
6699            if harness_mcp.is_none() {
6700                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6701            }
6702            return result;
6703        }
6704        let result = self
6705            .spawn_session_with_deadline(
6706                spawn_workspace_id,
6707                resolved.provider.clone(),
6708                resolved.mode,
6709                resolved.terminal_size,
6710                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6711                environment_profile.clone(),
6712                bundle.clone(),
6713                context.clone(),
6714                None,
6715                None,
6716                SpawnRecordPolicy::Always,
6717                Some(deadline),
6718                &required_capabilities,
6719                harness_mcp,
6720                resolved.approval_level,
6721                resolved.network_allowlist.clone(),
6722                resolved.browser_profile_id.clone(),
6723            )
6724            .await
6725            .map(|(session, _runtime_policy)| {
6726                resolved.receipt_with_materialization(
6727                    self.incarnation_id,
6728                    session,
6729                    environment_profile,
6730                    bundle,
6731                    context,
6732                )
6733            });
6734        if harness_mcp.is_none() {
6735            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6736        }
6737        result
6738    }
6739
6740    async fn spawn_from_spec_with_harness_mcp(
6741        &self,
6742        reservation_id: HarnessMcpReservationId,
6743        activation_digest: HarnessMcpActivationDigest,
6744        spec: SpawnSpec,
6745        deadline_unix_ms: u64,
6746    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6747        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6748            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6749        })?;
6750        if let Some(receipt) = registry
6751            .replay_spawn(&reservation_id, &activation_digest, &spec)
6752            .map_err(harness_mcp_failure)?
6753        {
6754            return Ok(receipt);
6755        }
6756        let resolved = self.resolve_spawn_spec(&spec)?;
6757        let prepared = registry
6758            .prepare_spawn(
6759                &reservation_id,
6760                &activation_digest,
6761                &spec,
6762                deadline_unix_ms,
6763                resolved.provider.clone(),
6764            )
6765            .map_err(harness_mcp_failure)?;
6766        self.claim_harness_mcp_spawn(
6767            &reservation_id,
6768            &activation_digest,
6769            &spec,
6770            Instant::now(),
6771        )?;
6772        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6773        let mut receipt = match spawn {
6774            Ok(receipt) => receipt,
6775            Err(error) => {
6776                let _ = registry.abort(&reservation_id, &activation_digest);
6777                return Err(error);
6778            }
6779        };
6780        let record_id = self
6781            .session_bindings
6782            .lock()
6783            .unwrap_or_else(|poisoned| poisoned.into_inner())
6784            .get(&receipt.session.session.instance_id)
6785            .and_then(|binding| binding.record_id.clone());
6786        let record_id = match record_id {
6787            Some(record_id) => record_id,
6788            None => {
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(failure(
6795                    NodeFailureCode::BindingMismatch,
6796                    "harness MCP spawn did not retain its exact managed record",
6797                ));
6798            }
6799        };
6800        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6801            reservation_id: reservation_id.clone(),
6802            activation_digest: activation_digest.clone(),
6803        });
6804        if let Err(error) = registry.mark_spawned(
6805            &prepared,
6806            receipt.session.clone(),
6807            record_id,
6808            receipt.clone(),
6809        ) {
6810            let rollback = self.remove_session(&receipt.session).await;
6811            let _ = registry.abort(&reservation_id, &activation_digest);
6812            if let Err(rollback_error) = rollback {
6813                return Err(rollback_error);
6814            }
6815            return Err(harness_mcp_failure(error));
6816        }
6817        Ok(receipt)
6818    }
6819
6820    async fn spawn_managed_worktree(
6821        &self,
6822        request: ManagedWorktreeSpawnRequest,
6823    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6824        self.spawn_managed_worktree_inner(request, None).await
6825    }
6826
6827    async fn spawn_managed_worktree_inner(
6828        &self,
6829        request: ManagedWorktreeSpawnRequest,
6830        durable_v2_key: Option<&SpawnIdempotencyKey>,
6831    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6832        let accepted_at = Instant::now();
6833        if durable_v2_key.is_none() {
6834            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6835                return replayed;
6836            }
6837        }
6838        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6839        if request.spawn_spec.target.worktree_id.is_some() {
6840            let result = Err(failure(
6841                NodeFailureCode::InvalidRequest,
6842                "managed worktree spawn must not select a caller-provided worktree",
6843            ));
6844            self.remember_managed_spawn_attempt(
6845                durable_v2_key,
6846                request,
6847                result.clone(),
6848                accepted_at,
6849            );
6850            return result;
6851        }
6852        let environment_profile = self.resolve_environment_profile(&resolved)?;
6853        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6854        let context = self.resolve_context(&resolved)?;
6855        let source_workspace_id = resolved.target.workspace_id.clone();
6856        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6857        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6858        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6859        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6860            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6861            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6862            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6863            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6864        };
6865        let runtime_policy = timeout(
6866            spawn_deadline_remaining(deadline)?,
6867            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6868        ).await.map_err(|_| failure(
6869            NodeFailureCode::SpawnDeadlineExceeded,
6870            "provider admission exceeded the managed spawn deadline",
6871        ))??;
6872        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6873            return Err(failure(
6874                NodeFailureCode::UnsupportedSpawnCapability,
6875                "provider runtime does not admit a required spawn capability",
6876            ));
6877        }
6878        self.ensure_binding_capacity()?;
6879        let source_root = self.workspace_root(&source_workspace_id)?;
6880        profile.validate_for_workspace(&source_root)
6881            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6882        let base_timeout_ms = u64::try_from(
6883            spawn_deadline_remaining(deadline)?.as_millis(),
6884        ).unwrap_or(u64::MAX).max(1);
6885        let base_commit = resolve_base_commit_with_timeout(
6886            &source_root,
6887            profile.base(),
6888            base_timeout_ms,
6889        ).await.map_err(|error| {
6890            if Instant::now() >= deadline || error.message.contains("timed out") {
6891                failure(
6892                    NodeFailureCode::SpawnDeadlineExceeded,
6893                    "managed worktree base resolution exceeded the spawn deadline",
6894                )
6895            } else {
6896                managed_git_worktree_failure(error)
6897            }
6898        })?;
6899
6900        let lease = {
6901            let _transaction = self.state_transaction.lock()
6902                .unwrap_or_else(|poisoned| poisoned.into_inner());
6903            let previous = self.managed_worktrees.lock()
6904                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6905            let lease = self.managed_worktrees.lock()
6906                .unwrap_or_else(|poisoned| poisoned.into_inner())
6907                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6908                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6909            let previous_replay = match durable_v2_key
6910                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6911                .transpose()
6912            {
6913                Ok(previous) => previous,
6914                Err(error) => {
6915                    *self.managed_worktrees.lock()
6916                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6917                    return Err(error);
6918                }
6919            };
6920            if let Err(error) = self.persist_state_locked() {
6921                *self.managed_worktrees.lock()
6922                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6923                if let (Some(key), Some(previous_replay)) =
6924                    (durable_v2_key, previous_replay)
6925                {
6926                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6927                }
6928                return Err(persistence_failure(error));
6929            }
6930            lease
6931        };
6932        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6933        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6934            self.managed_worktrees.lock()
6935                .unwrap_or_else(|poisoned| poisoned.into_inner())
6936                .remove_unmutated(&lease.lease_id);
6937            if self.persist_state().is_err() {
6938                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6939            }
6940            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6941            self.remember_managed_spawn_attempt(
6942                durable_v2_key,
6943                request,
6944                result.clone(),
6945                accepted_at,
6946            );
6947            return result;
6948        }
6949        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6950            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6951            Err(error) => {
6952                self.reconcile_failed_allocation(&lease, &source_root).await;
6953                let result = Err(error);
6954                self.remember_managed_spawn_attempt(
6955                    durable_v2_key,
6956                    request,
6957                    result.clone(),
6958                    accepted_at,
6959                );
6960                return result;
6961            }
6962        };
6963        let created = match create_git_worktree_with_timeout(
6964                &source_root,
6965                &lease.target_root,
6966                &lease.branch,
6967                Some(&base_commit),
6968                mutation_timeout_ms,
6969            ).await {
6970            Ok(created) => created,
6971            Err(error) => {
6972                self.reconcile_failed_allocation(&lease, &source_root).await;
6973                let result = Err(if Instant::now() >= deadline
6974                    || error.message.contains("timed out")
6975                    || error.message.contains("deadline")
6976                {
6977                    failure(
6978                        NodeFailureCode::SpawnDeadlineExceeded,
6979                        "managed worktree creation exceeded the spawn deadline",
6980                    )
6981                } else {
6982                    managed_git_worktree_failure(error)
6983                });
6984                self.remember_managed_spawn_attempt(
6985                    durable_v2_key,
6986                    request,
6987                    result.clone(),
6988                    accepted_at,
6989                );
6990                return result;
6991            }
6992        };
6993        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6994            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6995            let result = Err(failure(
6996                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6997                "created worktree identity did not match its durable lease",
6998            ));
6999            self.remember_managed_spawn_attempt(
7000                durable_v2_key,
7001                request,
7002                result.clone(),
7003                accepted_at,
7004            );
7005            return result;
7006        }
7007        {
7008            let mut registry = self.managed_worktrees.lock()
7009                .unwrap_or_else(|poisoned| poisoned.into_inner());
7010            let current = registry.get_mut(&lease.lease_id)
7011                .expect("allocated managed worktree lease remains present");
7012            current.expected_head = Some(created.head.clone());
7013            current.state = ManagedWorktreeLeaseState::Ready;
7014            current.updated_at_unix_ms = unix_time_ms();
7015        }
7016        if let Err(error) = self.persist_state() {
7017            self.mark_managed_recovery_required(
7018                &lease.lease_id,
7019                ManagedWorktreeCleanupFailure::Backend,
7020            );
7021            let result = Err(persistence_failure(error));
7022            self.remember_managed_spawn_attempt(
7023                durable_v2_key,
7024                request,
7025                result.clone(),
7026                accepted_at,
7027            );
7028            return result;
7029        }
7030        let ready = self.managed_worktrees.lock()
7031            .unwrap_or_else(|poisoned| poisoned.into_inner())
7032            .get(&lease.lease_id)
7033            .expect("ready managed worktree lease remains present")
7034            .snapshot();
7035        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7036        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7037            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7038            let result = Err(error);
7039            self.remember_managed_spawn_attempt(
7040                durable_v2_key,
7041                request,
7042                result.clone(),
7043                accepted_at,
7044            );
7045            return result;
7046        }
7047        let spawn = self.spawn_session_with_deadline(
7048            lease.workspace_id.clone(),
7049            resolved.provider.clone(),
7050            resolved.mode,
7051            resolved.terminal_size,
7052            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7053            environment_profile.clone(),
7054            bundle.clone(),
7055            context.clone(),
7056            Some(lease.lease_id.clone()),
7057            Some(runtime_policy),
7058            SpawnRecordPolicy::Always,
7059            Some(deadline),
7060            &required_capabilities,
7061            None,
7062            resolved.approval_level,
7063            resolved.network_allowlist.clone(),
7064            resolved.browser_profile_id.clone(),
7065        ).await;
7066        let (session, effective_runtime_policy) = match spawn {
7067            Ok(spawned) => spawned,
7068            Err(error) => {
7069                if self
7070                    .cleanup_managed_worktree(&lease.lease_id, true)
7071                    .await
7072                    .is_err()
7073                {
7074                    self.mark_managed_recovery_required(
7075                        &lease.lease_id,
7076                        ManagedWorktreeCleanupFailure::Backend,
7077                    );
7078                }
7079                let result = Err(error);
7080                self.remember_managed_spawn_attempt(
7081                    durable_v2_key,
7082                    request,
7083                    result.clone(),
7084                    accepted_at,
7085                );
7086                return result;
7087            }
7088        };
7089        let (record_id, raw_inventory_record_id) = {
7090            let mut bindings = self.session_bindings.lock()
7091                .unwrap_or_else(|poisoned| poisoned.into_inner());
7092            let binding = bindings.get_mut(&session.session.instance_id)
7093                .expect("successful spawn retains its session binding");
7094            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7095            managed_spawn_record_ownership(binding, effective_runtime_policy)
7096        };
7097        let bound = self.managed_worktrees.lock()
7098            .unwrap_or_else(|poisoned| poisoned.into_inner())
7099            .bind_session(
7100                &lease.lease_id,
7101                ManagedWorktreeSessionHolder {
7102                    incarnation_id: self.incarnation_id,
7103                    instance_id: session.session.instance_id,
7104                    generation: session.session.generation,
7105                },
7106                record_id,
7107                unix_time_ms(),
7108            )
7109            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7110        let snapshot = match bound {
7111            Ok(snapshot) => snapshot,
7112            Err(error) => {
7113                if raw_inventory_record_id
7114                    .as_ref()
7115                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7116                {
7117                    self.mark_managed_recovery_required(
7118                        &lease.lease_id,
7119                        ManagedWorktreeCleanupFailure::Backend,
7120                    );
7121                }
7122                let _ = self.remove_session(&session).await;
7123                self.mark_managed_recovery_required(
7124                    &lease.lease_id,
7125                    ManagedWorktreeCleanupFailure::Backend,
7126                );
7127                let result = Err(error);
7128                self.remember_managed_spawn_attempt(
7129                    durable_v2_key,
7130                    request,
7131                    result.clone(),
7132                    accepted_at,
7133                );
7134                return result;
7135            }
7136        };
7137        if let Err(error) = self.persist_state() {
7138            if raw_inventory_record_id
7139                .as_ref()
7140                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7141            {
7142                self.mark_managed_recovery_required(
7143                    &lease.lease_id,
7144                    ManagedWorktreeCleanupFailure::Backend,
7145                );
7146            }
7147            let _ = self.remove_session(&session).await;
7148            if self
7149                .cleanup_managed_worktree(&lease.lease_id, true)
7150                .await
7151                .is_err()
7152            {
7153                self.mark_managed_recovery_required(
7154                    &lease.lease_id,
7155                    ManagedWorktreeCleanupFailure::Backend,
7156                );
7157            }
7158            let result = Err(persistence_failure(error));
7159            self.remember_managed_spawn_attempt(
7160                durable_v2_key,
7161                request,
7162                result.clone(),
7163                accepted_at,
7164            );
7165            return result;
7166        }
7167        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7168        let receipt = managed_spawn_receipt(
7169            &resolved,
7170            self.incarnation_id,
7171            session,
7172            snapshot,
7173            environment_profile,
7174            bundle,
7175            context,
7176        );
7177        let result = Ok(receipt);
7178        self.remember_managed_spawn_attempt(
7179            durable_v2_key,
7180            request,
7181            result.clone(),
7182            accepted_at,
7183        );
7184        result
7185    }
7186
7187    async fn spawn_managed_worktree_v2(
7188        &self,
7189        request: ManagedWorktreeSpawnRequestV2,
7190    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7191        if request.spawn_spec.target.node_id != self.node_id {
7192            return Err(failure(
7193                NodeFailureCode::SpawnTargetMismatch,
7194                "spawn target does not match this node",
7195            ));
7196        }
7197        if request.spawn_spec.target.worktree_id.is_some() {
7198            return Err(failure(
7199                NodeFailureCode::InvalidRequest,
7200                "managed worktree spawn must not select a caller-provided worktree",
7201            ));
7202        }
7203        let request_digest = managed_spawn_request_digest_v2(&request)?;
7204        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7205        match replay {
7206            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7207            DurableManagedSpawnReplayDecision::PendingLinked => {
7208                return Err(failure(
7209                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7210                    "managed worktree V2 reservation has an incomplete durable allocation",
7211                ));
7212            }
7213            DurableManagedSpawnReplayDecision::Reserved => {}
7214        }
7215        let validated = (|| {
7216            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7217            let profile = self.managed_profile(
7218                &resolved.target.workspace_id,
7219                &request.worktree_profile_id,
7220            )?;
7221            if profile.revision() != &request.expected_profile_revision {
7222                return Err(failure(
7223                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7224                    "managed worktree profile revision does not match the loaded profile",
7225                ));
7226            }
7227            Ok(())
7228        })();
7229        match validated {
7230            Ok(()) => {}
7231            Err(error) => {
7232                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7233                return Err(error);
7234            }
7235        }
7236        let legacy = ManagedWorktreeSpawnRequest {
7237            spawn_spec: request.spawn_spec.clone(),
7238            worktree_profile_id: request.worktree_profile_id.clone(),
7239        };
7240        match self
7241            .spawn_managed_worktree_inner(
7242                legacy,
7243                Some(&request.spawn_spec.idempotency_key),
7244            )
7245            .await
7246        {
7247            Ok(receipt) => {
7248                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7249                Ok(receipt)
7250            }
7251            Err(error) => {
7252                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7253                Err(error)
7254            }
7255        }
7256    }
7257
7258    fn managed_profile(
7259        &self,
7260        workspace_id: &WorkspaceId,
7261        profile_id: &WorktreeProfileId,
7262    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7263        if self.worktree_service_modes.get(workspace_id).copied()
7264            != Some(WorktreeServiceMode::Managed)
7265        {
7266            return Err(failure(
7267                NodeFailureCode::UnsupportedSpawnCapability,
7268                "workspace does not enable managed worktrees",
7269            ));
7270        }
7271        self.managed_worktree_profiles.get(workspace_id)
7272            .and_then(|profiles| profiles.get(profile_id))
7273            .cloned()
7274            .ok_or_else(|| failure(
7275                NodeFailureCode::InvalidRequest,
7276                "managed worktree profile is unavailable for this workspace",
7277            ))
7278    }
7279
7280    async fn reconcile_failed_allocation(
7281        &self,
7282        lease: &ManagedWorktreeLeaseRecord,
7283        source_root: &str,
7284    ) {
7285        match list_git_worktrees(source_root).await {
7286            Ok(worktrees) => {
7287                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7288                    None => {
7289                        match std::fs::symlink_metadata(&lease.target_root) {
7290                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7291                                self.managed_worktrees.lock()
7292                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7293                                    .remove_unmutated(&lease.lease_id);
7294                                let _ = self.persist_state();
7295                            }
7296                            _ => self.mark_managed_recovery_required(
7297                                &lease.lease_id,
7298                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7299                            ),
7300                        }
7301                    }
7302                    Some(item) if exact_owned_worktree(
7303                        lease,
7304                        &item.path,
7305                        item.branch.as_deref(),
7306                        &item.head,
7307                    ) => {
7308                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7309                    }
7310                    Some(_) => self.mark_managed_recovery_required(
7311                        &lease.lease_id,
7312                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7313                    ),
7314                }
7315            }
7316            Err(_) => self.mark_managed_recovery_required(
7317                &lease.lease_id,
7318                ManagedWorktreeCleanupFailure::Backend,
7319            ),
7320        }
7321    }
7322
7323    async fn reconcile_managed_worktrees(&self) {
7324        let leases = self.managed_worktrees.lock()
7325            .unwrap_or_else(|poisoned| poisoned.into_inner())
7326            .active_records()
7327            .cloned()
7328            .collect::<Vec<_>>();
7329        for lease in leases {
7330            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7331                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7332                _ => {
7333                    self.mark_managed_recovery_required(
7334                        &lease.lease_id,
7335                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7336                    );
7337                    continue;
7338                }
7339            };
7340            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7341                self.mark_managed_recovery_required(
7342                    &lease.lease_id,
7343                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7344                );
7345                continue;
7346            }
7347            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7348                Ok(root) => root,
7349                Err(_) => {
7350                    self.mark_managed_recovery_required(
7351                        &lease.lease_id,
7352                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7353                    );
7354                    continue;
7355                }
7356            };
7357            let worktrees = match list_git_worktrees(&source_root).await {
7358                Ok(worktrees) => worktrees,
7359                Err(_) => {
7360                    self.mark_managed_recovery_required(
7361                        &lease.lease_id,
7362                        ManagedWorktreeCleanupFailure::Backend,
7363                    );
7364                    continue;
7365                }
7366            };
7367            let listed = worktrees.iter()
7368                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7369            let Some(listed) = listed else {
7370                match std::fs::symlink_metadata(&lease.target_root) {
7371                    Err(error) if error.kind() == io::ErrorKind::NotFound
7372                        && lease.state == ManagedWorktreeLeaseState::Allocating
7373                        && !lease.has_holders() => {
7374                            let removed = self.managed_worktrees.lock()
7375                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7376                                .tombstone(&lease.lease_id, unix_time_ms());
7377                            if self.persist_state().is_ok() && removed.is_some() {
7378                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7379                                    lease_id: lease.lease_id.clone(),
7380                                });
7381                            }
7382                        }
7383                    _ => self.mark_managed_recovery_required(
7384                        &lease.lease_id,
7385                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7386                    ),
7387                }
7388                continue;
7389            };
7390            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7391                &lease,
7392                &listed.path,
7393                listed.branch.as_deref(),
7394                &listed.head,
7395            ) {
7396                self.mark_managed_recovery_required(
7397                    &lease.lease_id,
7398                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7399                );
7400                continue;
7401            }
7402            if !self.workspaces.read()
7403                .unwrap_or_else(|poisoned| poisoned.into_inner())
7404                .contains_key(&lease.workspace_id)
7405            {
7406                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7407                    self.mark_managed_recovery_required(
7408                        &lease.lease_id,
7409                        ManagedWorktreeCleanupFailure::Backend,
7410                    );
7411                    continue;
7412                }
7413            }
7414            let snapshot = {
7415                let mut registry = self.managed_worktrees.lock()
7416                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7417                let current = registry.get_mut(&lease.lease_id)
7418                    .expect("reconciled managed lease remains present");
7419                current.expected_head = Some(listed.head.clone());
7420                current.cleanup_failure = None;
7421                current.state = if current.has_holders() {
7422                    ManagedWorktreeLeaseState::InUse
7423                } else if current.retention == ManagedWorktreeRetention::Retain {
7424                    ManagedWorktreeLeaseState::Retained
7425                } else {
7426                    ManagedWorktreeLeaseState::Ready
7427                };
7428                current.updated_at_unix_ms = unix_time_ms();
7429                current.snapshot()
7430            };
7431            if self.persist_state().is_err() {
7432                self.mark_managed_recovery_required(
7433                    &lease.lease_id,
7434                    ManagedWorktreeCleanupFailure::Backend,
7435                );
7436                continue;
7437            }
7438            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7439            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7440                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7441            }
7442        }
7443    }
7444
7445    /// Crash-recovery startup sweep: durably capture the pack for every
7446    /// `Live` managed record whose most recent session has not yet produced
7447    /// one (recovers a session whose `Exited{0}` event was observed but the
7448    /// Node crashed before the export finished). The reactive fast path for
7449    /// a single just-exited record does not go through this — see
7450    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7451    /// it can capture the exact `SessionAddress` before `publish_control`
7452    /// clears it, rather than re-deriving it from (by then likely already
7453    /// `Dormant`) record state the way a rescan naturally would.
7454    ///
7455    /// Returns as soon as every eligible record's export has been *spawned*,
7456    /// not completed — each one runs on its own detached task (see
7457    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7458    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7459    /// point this is called from `NodeServer::run()`, so nothing could ever
7460    /// drain the history round trip if this awaited it inline here either).
7461    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7462    /// is a silent skip for THIS record — an unusable source or a racing
7463    /// record is not surfaced to any caller.
7464    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7465        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7466            .session_records
7467            .lock()
7468            .unwrap_or_else(|poisoned| poisoned.into_inner())
7469            .records
7470            .values()
7471            .filter(|record| {
7472                record.state == ManagedSessionState::Live
7473                    && record.exported_context.is_none()
7474                    && record.provider_session.is_some()
7475            })
7476            .filter_map(|record| {
7477                record
7478                    .active_session
7479                    .clone()
7480                    .map(|session| (record.record_id.clone(), session))
7481            })
7482            .collect();
7483        for (record_id, session) in candidates {
7484            let shared = Arc::clone(self);
7485            tokio::spawn(async move {
7486                shared
7487                    .reconcile_context_pack_export_for_record(&record_id, session)
7488                    .await;
7489            });
7490        }
7491    }
7492
7493    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7494    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7495    /// round trip this performs (`discover_history`/`load_history`, via
7496    /// `export_context_pack_for_session_record`) can only ever settle once
7497    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7498    /// completion — and `tick()` is exclusively owned by, and only ever
7499    /// called from, that same drive-loop task. Awaiting this inline there
7500    /// would starve `tick()` and this call could never observe its own
7501    /// command complete.
7502    ///
7503    /// Because this now genuinely races the drive loop's own
7504    /// `publish_control` (which downgrades this exact record from `Live` to
7505    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7506    /// triggered this reconcile), the record legitimately may already be
7507    /// `Dormant` by the time this reads it, or may transition mid-flight —
7508    /// `allow_clean_detachment: true` on the downstream export call is what
7509    /// tolerates exactly that transition (and only that transition; see
7510    /// `session_record_export_target_matches`).
7511    async fn reconcile_context_pack_export_for_record(
7512        &self,
7513        record_id: &SessionRecordId,
7514        session: SessionAddress,
7515    ) {
7516        let Ok(record) = self.record(record_id) else {
7517            return;
7518        };
7519        if !matches!(
7520            record.state,
7521            ManagedSessionState::Live | ManagedSessionState::Dormant
7522        ) || record.exported_context.is_some()
7523            || record.provider_session.is_none()
7524        {
7525            return;
7526        }
7527        // Pack bytes are already durably committed inside this call, via
7528        // commit_context_pack_for_session_record's own durable
7529        // write-through (context_pack_store) — no separate step here.
7530        // Unclean exits (non-zero/forced/no loadable history) are already
7531        // rejected by context_pack_source_status_is_usable inside
7532        // materialize_context_pack: this simply returns Err and is skipped.
7533        let Ok(receipt) = self
7534            .export_context_pack_for_session_record(&record.record_id, &session, true)
7535            .await
7536        else {
7537            return;
7538        };
7539        self.set_exported_context(&record.record_id, receipt);
7540    }
7541
7542    /// Records the record's most recent clean-exit pack. No-op (not an
7543    /// error) if the record disappeared, or if it already carries an
7544    /// `exported_context` — first-write-wins, matching the harness-side
7545    /// idempotent reconciliation that consumes this field.
7546    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7547        let transaction = self
7548            .state_transaction
7549            .lock()
7550            .unwrap_or_else(|poisoned| poisoned.into_inner());
7551        let (previous, updated) = {
7552            let mut records = self
7553                .session_records
7554                .lock()
7555                .unwrap_or_else(|poisoned| poisoned.into_inner());
7556            let Some(record) = records.records.get_mut(record_id) else {
7557                return;
7558            };
7559            if record.exported_context.is_some() {
7560                return;
7561            }
7562            let previous = record.clone();
7563            record.exported_context = Some(receipt);
7564            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7565            (previous, record.clone())
7566        };
7567        if self.persist_state_locked().is_err() {
7568            self.session_records
7569                .lock()
7570                .unwrap_or_else(|poisoned| poisoned.into_inner())
7571                .records
7572                .insert(record_id.clone(), previous);
7573            return;
7574        }
7575        drop(transaction);
7576        self.publish_record(updated);
7577    }
7578
7579    /// Resolves the managed record and its exact `SessionAddress` currently
7580    /// bound to `instance_id` at exactly `generation`, used by the reactive
7581    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7582    /// record without a full rescan. Mirrors the lookup already inlined in
7583    /// `publish_control`. Captured synchronously, before `publish_control`
7584    /// for the same event clears the record's own `active_session` — the
7585    /// caller passes the resolved address through to a detached
7586    /// reconciliation task rather than re-deriving it from the record later,
7587    /// after that race has necessarily already happened.
7588    fn managed_record_export_target_for_instance(
7589        &self,
7590        instance_id: AgentInstanceId,
7591        generation: SessionGeneration,
7592    ) -> Option<(SessionRecordId, SessionAddress)> {
7593        let bindings = self
7594            .session_bindings
7595            .lock()
7596            .unwrap_or_else(|poisoned| poisoned.into_inner());
7597        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7598        let record_id = binding.record_id.clone()?;
7599        Some((
7600            record_id,
7601            SessionAddress {
7602                workspace_id: binding.workspace_id.clone(),
7603                session: SessionKey { instance_id, generation },
7604            },
7605        ))
7606    }
7607
7608    fn mark_managed_recovery_required(
7609        &self,
7610        lease_id: &ManagedWorktreeLeaseId,
7611        failure_kind: ManagedWorktreeCleanupFailure,
7612    ) {
7613        let snapshot = {
7614            let mut registry = self.managed_worktrees.lock()
7615                .unwrap_or_else(|poisoned| poisoned.into_inner());
7616            let Some(lease) = registry.get_mut(lease_id) else { return };
7617            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7618            lease.cleanup_failure = Some(failure_kind);
7619            lease.updated_at_unix_ms = unix_time_ms();
7620            lease.snapshot()
7621        };
7622        let _ = self.persist_state();
7623        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7624    }
7625
7626    fn set_persistence_error(&self, error: Option<String>) {
7627        *self
7628            .persistence_error
7629            .write()
7630            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7631            .map(|message| session_registry::sanitized_persistence_summary(&message));
7632    }
7633
7634    fn persist_state(&self) -> io::Result<()> {
7635        let _transaction = self
7636            .state_transaction
7637            .lock()
7638            .unwrap_or_else(|poisoned| poisoned.into_inner());
7639        self.persist_state_locked()
7640    }
7641
7642    fn persist_state_locked(&self) -> io::Result<()> {
7643        let workspaces = self
7644            .workspaces
7645            .read()
7646            .unwrap_or_else(|poisoned| poisoned.into_inner())
7647            .clone();
7648        let records = self
7649            .session_records
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner())
7652            .records
7653            .values()
7654            .cloned()
7655            .collect::<Vec<_>>();
7656        let mut managed = self.managed_worktrees
7657            .lock()
7658            .unwrap_or_else(|poisoned| poisoned.into_inner());
7659        managed.reattach_record_holders(
7660            &managed_worktree_record_holders(&records),
7661            unix_time_ms(),
7662        );
7663        let materializations = self
7664            .materializations
7665            .lock()
7666            .unwrap_or_else(|poisoned| poisoned.into_inner())
7667            .values()
7668            .cloned()
7669            .collect::<Vec<_>>();
7670        let managed_spawn_replays = self.managed_spawn_replays
7671            .lock()
7672            .unwrap_or_else(|poisoned| poisoned.into_inner())
7673            .values()
7674            .cloned()
7675            .collect::<Vec<_>>();
7676        let result = session_registry::save_v10(
7677            self.state_path.as_deref(),
7678            &self.node_id,
7679            &workspaces,
7680            &records,
7681            &managed.records(),
7682            &managed.tombstones(),
7683            &materializations,
7684            &managed_spawn_replays,
7685        );
7686        match &result {
7687            Ok(warning) => self.set_persistence_error(warning.clone()),
7688            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7689        }
7690        result.map(|_| ())
7691    }
7692
7693    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7694        let _mutation_guard = self.mutation_gate.lock().await;
7695        self.begin_shutdown_locked().await
7696    }
7697
7698    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7699        if self.shutdown.swap(true, Ordering::AcqRel) {
7700            return Ok(());
7701        }
7702        SessionLifecycleHook::on_shutdown(self);
7703        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7704        let mut first_error = None;
7705        for session in &self.handle.snapshot().sessions {
7706            let remaining = deadline.saturating_duration_since(Instant::now());
7707            if remaining.is_zero() {
7708                first_error.get_or_insert_with(|| {
7709                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7710                });
7711                break;
7712            }
7713            if let Err(error) = self
7714                .dispatch_bounded(
7715                    ControlCommand::Stop {
7716                        instance_id: session.instance_id,
7717                        force: true,
7718                    },
7719                    remaining,
7720                )
7721                .await
7722            {
7723                first_error.get_or_insert(error);
7724            }
7725        }
7726        self.shutdown_notify.notify_waiters();
7727        match first_error {
7728            Some(error) => Err(error),
7729            None => Ok(()),
7730        }
7731    }
7732
7733    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7734        let bindings = self
7735            .session_bindings
7736            .lock()
7737            .unwrap_or_else(|poisoned| poisoned.into_inner());
7738        if bindings.len() >= CONTROL_SESSIONS_MAX {
7739            Err(failure(
7740                NodeFailureCode::BackendBusy,
7741                "node session-binding capacity is full; remove an existing session first",
7742            ))
7743        } else {
7744            Ok(())
7745        }
7746    }
7747
7748    /// One-owner workspace/env collision guard: a live session on
7749    /// `workspace_id` with a different `environment_profile` (option
7750    /// equality on the resolved receipt, including Some vs None) blocks
7751    /// another spawn on that same cwd. Same profile may still share the
7752    /// workspace; distinct profiles need distinct workspace ids (managed
7753    /// worktree).
7754    fn ensure_workspace_environment_profile_compatible(
7755        &self,
7756        workspace_id: &WorkspaceId,
7757        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7758    ) -> Result<(), NodeFailure> {
7759        let bindings = self
7760            .session_bindings
7761            .lock()
7762            .unwrap_or_else(|poisoned| poisoned.into_inner());
7763        for binding in bindings.values() {
7764            if &binding.workspace_id != workspace_id {
7765                continue;
7766            }
7767            if binding.environment_profile.as_ref() != environment_profile {
7768                return Err(failure(
7769                    NodeFailureCode::WorkspaceBusy,
7770                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7771                ));
7772            }
7773        }
7774        Ok(())
7775    }
7776
7777    #[cfg(test)]
7778    fn bind_session(
7779        &self,
7780        instance_id: AgentInstanceId,
7781        workspace_id: WorkspaceId,
7782        generation: SessionGeneration,
7783    ) {
7784        self.bind_session_with_policy(
7785            &SessionAddress {
7786                workspace_id,
7787                session: SessionKey { instance_id, generation },
7788            },
7789            ProviderRuntimePolicy::raw_pty(),
7790            None,
7791        );
7792    }
7793
7794    #[cfg(test)]
7795    fn bind_session_with_policy(
7796        &self,
7797        address: &SessionAddress,
7798        runtime_policy: ProviderRuntimePolicy,
7799        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7800    ) {
7801        self.bind_session_with_materialization(
7802            address,
7803            runtime_policy,
7804            environment_profile,
7805            None,
7806            None,
7807            None,
7808        );
7809    }
7810
7811    fn bind_session_with_materialization(
7812        &self,
7813        address: &SessionAddress,
7814        runtime_policy: ProviderRuntimePolicy,
7815        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7816        bundle: Option<ResolvedBundleReceipt>,
7817        context: Option<ResolvedContextPackReceipt>,
7818        materialization_id: Option<MaterializationId>,
7819    ) {
7820        let mut bindings = self
7821            .session_bindings
7822            .lock()
7823            .unwrap_or_else(|poisoned| poisoned.into_inner());
7824        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7825        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7826        bindings.insert(address.session.instance_id, SessionBinding {
7827            workspace_id: address.workspace_id.clone(),
7828            generation: address.session.generation,
7829            runtime_policy,
7830            pending_resume: None,
7831            record_id: None,
7832            managed_worktree_lease_id: None,
7833            environment_profile,
7834            bundle,
7835            context,
7836            materialization_id,
7837        });
7838    }
7839
7840    #[cfg(test)]
7841    fn bind_managed_session(
7842        &self,
7843        address: &SessionAddress,
7844        record_id: SessionRecordId,
7845        runtime_policy: ProviderRuntimePolicy,
7846        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7847    ) {
7848        self.bind_managed_session_with_materialization(
7849            address,
7850            record_id,
7851            runtime_policy,
7852            environment_profile,
7853            None,
7854            None,
7855            None,
7856        );
7857    }
7858
7859    fn bind_managed_session_with_materialization(
7860        &self,
7861        address: &SessionAddress,
7862        record_id: SessionRecordId,
7863        runtime_policy: ProviderRuntimePolicy,
7864        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7865        bundle: Option<ResolvedBundleReceipt>,
7866        context: Option<ResolvedContextPackReceipt>,
7867        materialization_id: Option<MaterializationId>,
7868    ) {
7869        let mut bindings = self
7870            .session_bindings
7871            .lock()
7872            .unwrap_or_else(|poisoned| poisoned.into_inner());
7873        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7874        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7875        bindings.insert(
7876            address.session.instance_id,
7877            SessionBinding {
7878                workspace_id: address.workspace_id.clone(),
7879                generation: address.session.generation,
7880                runtime_policy,
7881                pending_resume: None,
7882                record_id: Some(record_id),
7883                managed_worktree_lease_id: None,
7884                environment_profile,
7885                bundle,
7886                context,
7887                materialization_id,
7888            },
7889        );
7890    }
7891
7892    #[cfg(test)]
7893    fn bind_spawn_session(
7894        &self,
7895        address: &SessionAddress,
7896        provider: AgentId,
7897        mode: SessionMode,
7898        runtime_policy: ProviderRuntimePolicy,
7899        record_policy: SpawnRecordPolicy,
7900        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7901    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7902        self.bind_spawn_session_with_materialization(
7903            address,
7904            provider,
7905            mode,
7906            runtime_policy,
7907            record_policy,
7908            environment_profile,
7909            None,
7910            None,
7911            None,
7912        )
7913    }
7914
7915    fn bind_spawn_session_with_materialization(
7916        &self,
7917        address: &SessionAddress,
7918        provider: AgentId,
7919        mode: SessionMode,
7920        runtime_policy: ProviderRuntimePolicy,
7921        record_policy: SpawnRecordPolicy,
7922        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7923        bundle: Option<ResolvedBundleReceipt>,
7924        context: Option<ResolvedContextPackReceipt>,
7925        materialization_id: Option<MaterializationId>,
7926    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7927        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7928            && !runtime_policy.provider_session_identity
7929        {
7930            self.bind_session_with_materialization(
7931                address,
7932                runtime_policy,
7933                environment_profile,
7934                bundle,
7935                context,
7936                materialization_id,
7937            );
7938            return Ok(None);
7939        }
7940        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7941        let record = self.new_record(
7942            address,
7943            provider,
7944            mode,
7945            active_record_state(runtime_policy.provider_session_identity, false),
7946            None,
7947            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7948            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7949            record_owns_lifecycle.then(|| context.clone()).flatten(),
7950        )?;
7951        let record_id = record.record_id.clone();
7952        let transaction = self
7953            .state_transaction
7954            .lock()
7955            .unwrap_or_else(|poisoned| poisoned.into_inner());
7956        self.insert_record(record.clone())?;
7957        let previous_materialization = if record_owns_lifecycle {
7958            if let Some(materialization_id) = materialization_id.as_ref() {
7959                let mut materializations = self
7960                    .materializations
7961                    .lock()
7962                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7963                let Some(ownership) = materializations.get_mut(materialization_id) else {
7964                    drop(materializations);
7965                    self.remove_record_memory(&record_id);
7966                    return Err(failure(
7967                        NodeFailureCode::BackendOperationFailed,
7968                        "session environment ownership disappeared before binding",
7969                    ));
7970                };
7971                let previous = ownership.clone();
7972                if ownership
7973                    .transfer_to_record(record_id.clone(), unix_time_ms())
7974                    .is_err()
7975                {
7976                    drop(materializations);
7977                    self.remove_record_memory(&record_id);
7978                    return Err(failure(
7979                        NodeFailureCode::BackendOperationFailed,
7980                        "session environment ownership transfer failed",
7981                    ));
7982                }
7983                Some(previous)
7984            } else {
7985                None
7986            }
7987        } else {
7988            None
7989        };
7990        self.bind_managed_session_with_materialization(
7991            address,
7992            record_id.clone(),
7993            runtime_policy,
7994            environment_profile,
7995            bundle,
7996            context,
7997            materialization_id.clone(),
7998        );
7999        if let Err(error) = self.persist_state_locked() {
8000            self.remove_binding(address);
8001            self.remove_record_memory(&record_id);
8002            if let (Some(materialization_id), Some(previous)) =
8003                (materialization_id.as_ref(), previous_materialization)
8004            {
8005                self.materializations
8006                    .lock()
8007                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8008                    .insert(materialization_id.clone(), previous);
8009            }
8010            return Err(persistence_failure(error));
8011        }
8012        drop(transaction);
8013        self.publish_record(record);
8014        Ok(Some(record_id))
8015    }
8016
8017    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8018        for _ in 0..8 {
8019            let bytes = random_nonce().map_err(|error| {
8020                failure(
8021                    NodeFailureCode::BackendOperationFailed,
8022                    &format!("session record identity generation failed: {error}"),
8023                )
8024            })?;
8025            let mut value = String::with_capacity(27);
8026            value.push_str("sr-");
8027            for byte in &bytes[..12] {
8028                use std::fmt::Write as _;
8029                let _ = write!(&mut value, "{byte:02x}");
8030            }
8031            let record_id = SessionRecordId::new(value).map_err(|error| {
8032                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8033            })?;
8034            let records = self
8035                .session_records
8036                .lock()
8037                .unwrap_or_else(|poisoned| poisoned.into_inner());
8038            if !records.records.contains_key(&record_id) {
8039                return Ok(record_id);
8040            }
8041        }
8042        Err(failure(
8043            NodeFailureCode::BackendBusy,
8044            "could not allocate a unique session record ID",
8045        ))
8046    }
8047
8048    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8049        for _ in 0..8 {
8050            let bytes = random_nonce().map_err(|error| {
8051                failure(
8052                    NodeFailureCode::BackendOperationFailed,
8053                    &format!("task identity generation failed: {error}"),
8054                )
8055            })?;
8056            let mut nonce = [0_u8; 12];
8057            nonce.copy_from_slice(&bytes[..12]);
8058            let task_id = TaskId::from_nonce(nonce);
8059            let records = self
8060                .session_records
8061                .lock()
8062                .unwrap_or_else(|poisoned| poisoned.into_inner());
8063            if !records.records.values().any(|record| {
8064                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8065                    == Some(&task_id)
8066            }) {
8067                return Ok(task_id);
8068            }
8069        }
8070        Err(failure(
8071            NodeFailureCode::BackendBusy,
8072            "could not allocate a unique task ID",
8073        ))
8074    }
8075
8076    fn default_record_name(&self, provider: &AgentId) -> String {
8077        let records = self
8078            .session_records
8079            .lock()
8080            .unwrap_or_else(|poisoned| poisoned.into_inner());
8081        let ordinal = records
8082            .records
8083            .values()
8084            .filter(|record| &record.provider == provider)
8085            .count()
8086            .saturating_add(1);
8087        format!("{} #{ordinal}", provider.as_str())
8088    }
8089
8090    fn new_record(
8091        &self,
8092        address: &SessionAddress,
8093        provider: AgentId,
8094        mode: SessionMode,
8095        state: ManagedSessionState,
8096        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8097        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8098        bundle: Option<ResolvedBundleReceipt>,
8099        context: Option<ResolvedContextPackReceipt>,
8100    ) -> Result<ManagedSessionRecord, NodeFailure> {
8101        let canonical_root = self.workspace_root(&address.workspace_id)?;
8102        let now = unix_time_ms();
8103        Ok(ManagedSessionRecord {
8104            record_id: self.allocate_record_id()?,
8105            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8106            provider,
8107            mode,
8108            state,
8109            workspace_id: address.workspace_id.clone(),
8110            canonical_root: opaque_windows_path(canonical_root),
8111            provider_session,
8112            active_session: Some(address.clone()),
8113            environment_profile,
8114            bundle,
8115            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8116            context,
8117            exported_context: None,
8118            task_binding: None,
8119            created_at_unix_ms: now,
8120            updated_at_unix_ms: now,
8121            last_error: None,
8122        })
8123    }
8124
8125    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8126        let mut records = self
8127            .session_records
8128            .lock()
8129            .unwrap_or_else(|poisoned| poisoned.into_inner());
8130        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8131            return Err(failure(
8132                NodeFailureCode::BackendBusy,
8133                "managed session record capacity is full; forget an inactive record first",
8134            ));
8135        }
8136        if let Some(identity) = record.provider_session.as_ref() {
8137            if records.records.values().any(|candidate| {
8138                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8139                    session_registry::same_provider_session(
8140                        &candidate.provider,
8141                        candidate_identity,
8142                        &record.provider,
8143                        identity,
8144                    )
8145                })
8146            }) {
8147                return Err(failure(
8148                    NodeFailureCode::SessionRecordConflict,
8149                    "provider session is already represented by another managed record",
8150                ));
8151            }
8152        }
8153        match records.records.entry(record.record_id.clone()) {
8154            std::collections::btree_map::Entry::Vacant(entry) => {
8155                entry.insert(record);
8156            }
8157            std::collections::btree_map::Entry::Occupied(_) => {
8158                return Err(failure(
8159                    NodeFailureCode::SessionRecordConflict,
8160                    "session record ID collision",
8161                ));
8162            }
8163        }
8164        Ok(())
8165    }
8166
8167    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8168        self.session_records
8169            .lock()
8170            .unwrap_or_else(|poisoned| poisoned.into_inner())
8171            .records
8172            .get(record_id)
8173            .cloned()
8174            .ok_or_else(|| {
8175                failure(
8176                    NodeFailureCode::UnknownSessionRecord,
8177                    "managed session record does not exist",
8178                )
8179            })
8180    }
8181
8182    fn revalidate_session_record_preview(
8183        &self,
8184        expected: &ManagedSessionRecord,
8185        identity: &ProviderSessionIdentity,
8186    ) -> Result<(), NodeFailure> {
8187        let current = self.record(&expected.record_id)?;
8188        let current_root = self.workspace_root(&current.workspace_id)?;
8189        if current.provider != expected.provider
8190            || current.workspace_id != expected.workspace_id
8191            || current.canonical_root != expected.canonical_root
8192            || current.provider_session.as_ref() != Some(identity)
8193            || !platform::roots_equal(
8194                &current_root,
8195                windows_path_text(&current.canonical_root),
8196            )
8197        {
8198            return Err(failure(
8199                NodeFailureCode::SessionRecordConflict,
8200                "managed session changed while its preview was loading",
8201            ));
8202        }
8203        Ok(())
8204    }
8205
8206    fn revalidate_session_record_context_export(
8207        &self,
8208        expected: &ManagedSessionRecord,
8209        identity: Option<&ProviderSessionIdentity>,
8210        session: &SessionAddress,
8211        allow_clean_detachment: bool,
8212    ) -> Result<(), NodeFailure> {
8213        let current = self.record(&expected.record_id)?;
8214        let current_root = self.workspace_root(&current.workspace_id)?;
8215        let snapshot = self.internal_session_snapshot(session)?;
8216        let binding = self
8217            .session_bindings
8218            .lock()
8219            .unwrap_or_else(|poisoned| poisoned.into_inner())
8220            .get(&session.session.instance_id)
8221            .cloned()
8222            .ok_or_else(|| {
8223                failure(
8224                    NodeFailureCode::SessionRecordConflict,
8225                    "managed session runtime binding disappeared during context export",
8226                )
8227            })?;
8228        if !session_record_context_export_binding_is_exact(
8229            expected,
8230            &current,
8231            identity,
8232            session,
8233            &binding,
8234            &snapshot.agent_id,
8235            &current_root,
8236            allow_clean_detachment,
8237        ) || !session_record_context_export_source_is_usable(
8238            &current,
8239            &snapshot.status,
8240            allow_clean_detachment,
8241        ) {
8242            return Err(failure(
8243                NodeFailureCode::SessionRecordConflict,
8244                "managed session binding changed during context export",
8245            ));
8246        }
8247        Ok(())
8248    }
8249
8250    fn revalidate_loaded_history(
8251        &self,
8252        session: &SessionAddress,
8253        candidate_id: &str,
8254        loaded: &HistorySessionRecord,
8255    ) -> Result<(), NodeFailure> {
8256        let snapshot = self.internal_session_snapshot(session)?;
8257        if snapshot.history.pending.is_some()
8258            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8259            || snapshot.history.loaded.as_ref() != Some(loaded)
8260        {
8261            return Err(failure(
8262                NodeFailureCode::SessionRecordConflict,
8263                "managed session history changed during context export",
8264            ));
8265        }
8266        Ok(())
8267    }
8268
8269    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8270        self.session_records
8271            .lock()
8272            .unwrap_or_else(|poisoned| poisoned.into_inner())
8273            .records
8274            .remove(record_id)
8275    }
8276
8277    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8278        if self.destroy_record_materialization(record_id)? {
8279            self.publish(NodeEvent::SessionRecordRemoved {
8280                record_id: record_id.clone(),
8281            });
8282            return Ok(());
8283        }
8284        let transaction = self
8285            .state_transaction
8286            .lock()
8287            .unwrap_or_else(|poisoned| poisoned.into_inner());
8288        let Some(removed) = self.remove_record_memory(record_id) else {
8289            return Ok(());
8290        };
8291        if let Err(error) = self.persist_state_locked() {
8292            let _ = self.insert_record(removed);
8293            return Err(persistence_failure(error));
8294        }
8295        drop(transaction);
8296        self.publish(NodeEvent::SessionRecordRemoved {
8297            record_id: record_id.clone(),
8298        });
8299        Ok(())
8300    }
8301
8302    fn publish_record(&self, record: ManagedSessionRecord) {
8303        self.publish(NodeEvent::SessionRecordUpserted { record });
8304    }
8305
8306    fn mark_record_error(
8307        &self,
8308        record_id: &SessionRecordId,
8309        message: &str,
8310    ) -> Result<(), NodeFailure> {
8311        let transaction = self
8312            .state_transaction
8313            .lock()
8314            .unwrap_or_else(|poisoned| poisoned.into_inner());
8315        let previous = {
8316            let mut records = self
8317                .session_records
8318                .lock()
8319                .unwrap_or_else(|poisoned| poisoned.into_inner());
8320            let Some(record) = records.records.get_mut(record_id) else {
8321                return Ok(());
8322            };
8323            let previous = record.clone();
8324            record.state = ManagedSessionState::Unavailable;
8325            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8326            record.updated_at_unix_ms = unix_time_ms();
8327            previous
8328        };
8329        if let Err(error) = self.persist_state_locked() {
8330            self.session_records
8331                .lock()
8332                .unwrap_or_else(|poisoned| poisoned.into_inner())
8333                .records
8334                .insert(record_id.clone(), previous);
8335            return Err(persistence_failure(error));
8336        }
8337        let record = self.record(record_id)?;
8338        drop(transaction);
8339        self.publish_record(record);
8340        Ok(())
8341    }
8342
8343    fn rename_session_record(
8344        &self,
8345        record_id: &SessionRecordId,
8346        display_name: String,
8347    ) -> Result<ManagedSessionRecord, NodeFailure> {
8348        validate_display_name(&display_name)
8349            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8350        let _transaction = self
8351            .state_transaction
8352            .lock()
8353            .unwrap_or_else(|poisoned| poisoned.into_inner());
8354        let previous = {
8355            let mut records = self
8356                .session_records
8357                .lock()
8358                .unwrap_or_else(|poisoned| poisoned.into_inner());
8359            let record = records.records.get_mut(record_id).ok_or_else(|| {
8360                failure(
8361                    NodeFailureCode::UnknownSessionRecord,
8362                    "managed session record does not exist",
8363                )
8364            })?;
8365            let previous = record.clone();
8366            record.display_name = display_name;
8367            record.updated_at_unix_ms = unix_time_ms();
8368            previous
8369        };
8370        if let Err(error) = self.persist_state_locked() {
8371            self.session_records
8372                .lock()
8373                .unwrap_or_else(|poisoned| poisoned.into_inner())
8374                .records
8375                .insert(record_id.clone(), previous);
8376            return Err(persistence_failure(error));
8377        }
8378        let updated = self.record(record_id)?;
8379        self.publish_record(updated.clone());
8380        Ok(updated)
8381    }
8382
8383    fn set_session_task(
8384        &self,
8385        record_id: &SessionRecordId,
8386        expected_revision: u64,
8387        target: SessionTaskTargetV1,
8388    ) -> Result<ManagedSessionRecord, NodeFailure> {
8389        let transaction = self
8390            .state_transaction
8391            .lock()
8392            .unwrap_or_else(|poisoned| poisoned.into_inner());
8393        let current = self.record(record_id)?;
8394        let current_task_id = current
8395            .task_binding
8396            .as_ref()
8397            .and_then(|binding| binding.task_id.as_ref());
8398        let current_revision = current
8399            .task_binding
8400            .as_ref()
8401            .map_or(0, |binding| binding.revision);
8402        if expected_revision != current_revision {
8403            return Err(failure(
8404                NodeFailureCode::SessionRecordConflict,
8405                "managed session task binding revision changed",
8406            ));
8407        }
8408        let exact = match &target {
8409            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8410            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8411            SessionTaskTargetV1::New => false,
8412        };
8413        if exact {
8414            return Ok(current);
8415        }
8416        let revision = current_revision.checked_add(1).ok_or_else(|| {
8417            failure(
8418                NodeFailureCode::SessionRecordConflict,
8419                "managed session task binding revision is exhausted",
8420            )
8421        })?;
8422        let task_id = match target {
8423            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8424            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8425            SessionTaskTargetV1::Clear => None,
8426        };
8427        let changed_at_unix_ms = unix_time_ms()
8428            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8429                failure(
8430                    NodeFailureCode::SessionRecordConflict,
8431                    "managed session record timestamp is exhausted",
8432                )
8433            })?);
8434        let updated = {
8435            let mut records = self
8436                .session_records
8437                .lock()
8438                .unwrap_or_else(|poisoned| poisoned.into_inner());
8439            let record = records.records.get_mut(record_id).ok_or_else(|| {
8440                failure(
8441                    NodeFailureCode::UnknownSessionRecord,
8442                    "managed session record does not exist",
8443                )
8444            })?;
8445            record.task_binding = Some(SessionTaskBindingV1 {
8446                revision,
8447                task_id,
8448                changed_at_unix_ms,
8449            });
8450            record.updated_at_unix_ms = changed_at_unix_ms;
8451            record.clone()
8452        };
8453        if let Err(error) = self.persist_state_locked() {
8454            self.session_records
8455                .lock()
8456                .unwrap_or_else(|poisoned| poisoned.into_inner())
8457                .records
8458                .insert(record_id.clone(), current);
8459            return Err(persistence_failure(error));
8460        }
8461        drop(transaction);
8462        self.publish_record(updated.clone());
8463        Ok(updated)
8464    }
8465
8466    fn index_provider_session(
8467        &self,
8468        workspace_id: WorkspaceId,
8469        provider: AgentId,
8470        identity: ProviderSessionIdentity,
8471        display_name: String,
8472    ) -> Result<ManagedSessionRecord, NodeFailure> {
8473        self.index_provider_session_with_policy(
8474            workspace_id,
8475            provider,
8476            identity,
8477            display_name,
8478            false,
8479        )
8480    }
8481
8482    fn index_provider_session_with_policy(
8483        &self,
8484        workspace_id: WorkspaceId,
8485        provider: AgentId,
8486        identity: ProviderSessionIdentity,
8487        display_name: String,
8488        allow_validated_transcript_path: bool,
8489    ) -> Result<ManagedSessionRecord, NodeFailure> {
8490        validate_display_name(&display_name)
8491            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8492        identity
8493            .validate()
8494            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8495        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8496            return Err(failure(
8497                NodeFailureCode::InvalidRequest,
8498                "provider session reference index v1 does not accept transcript paths",
8499            ));
8500        }
8501        let canonical_root = self.workspace_root(&workspace_id)?;
8502        if !self.enabled_providers.contains(&provider) {
8503            return Err(failure(
8504                NodeFailureCode::InvalidRequest,
8505                "provider is not enabled on this node",
8506            ));
8507        }
8508
8509        let transaction = self
8510            .state_transaction
8511            .lock()
8512            .unwrap_or_else(|poisoned| poisoned.into_inner());
8513        {
8514            let records = self
8515                .session_records
8516                .lock()
8517                .unwrap_or_else(|poisoned| poisoned.into_inner());
8518            if let Some(existing) = records.records.values().find(|candidate| {
8519                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8520                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8521                })
8522            }) {
8523                if existing.provider == provider && existing.workspace_id == workspace_id {
8524                    return Ok(existing.clone());
8525                }
8526                return Err(failure(
8527                    NodeFailureCode::SessionRecordConflict,
8528                    "provider session reference conflicts with another provider or workspace",
8529                ));
8530            }
8531            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8532                return Err(failure(
8533                    NodeFailureCode::BackendBusy,
8534                    "managed session record capacity is full; forget an inactive record first",
8535                ));
8536            }
8537        }
8538
8539        let now = unix_time_ms();
8540        let record = ManagedSessionRecord {
8541            record_id: self.allocate_record_id()?,
8542            display_name,
8543            provider,
8544            mode: SessionMode::Pty,
8545            state: ManagedSessionState::Dormant,
8546            workspace_id,
8547            canonical_root: opaque_windows_path(canonical_root),
8548            provider_session: Some(identity),
8549            active_session: None,
8550            environment_profile: None,
8551            bundle: None,
8552            context_id: None,
8553            context: None,
8554            exported_context: None,
8555            task_binding: None,
8556            created_at_unix_ms: now,
8557            updated_at_unix_ms: now,
8558            last_error: None,
8559        };
8560        self.insert_record(record.clone())?;
8561        if let Err(error) = self.persist_state_locked() {
8562            self.remove_record_memory(&record.record_id);
8563            return Err(persistence_failure(error));
8564        }
8565        drop(transaction);
8566        self.publish_record(record.clone());
8567        Ok(record)
8568    }
8569
8570    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8571        self.session_bindings
8572            .lock()
8573            .unwrap_or_else(|poisoned| poisoned.into_inner())
8574            .iter()
8575            .find_map(|(instance_id, binding)| {
8576                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8577                    workspace_id: binding.workspace_id.clone(),
8578                    session: SessionKey {
8579                        instance_id: *instance_id,
8580                        generation: binding.generation,
8581                    },
8582                })
8583            })
8584    }
8585
8586    async fn forget_session_record(
8587        &self,
8588        record_id: &SessionRecordId,
8589    ) -> Result<(), NodeFailure> {
8590        let record = self.record(record_id)?;
8591        if record.active_session.is_some()
8592            || matches!(
8593                record.state,
8594                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8595            )
8596        {
8597            return Err(failure(
8598                NodeFailureCode::SessionRecordBusy,
8599                "stop the managed session before forgetting it",
8600            ));
8601        }
8602        if let Some(address) = self.bound_address_for_record(record_id) {
8603            self.remove_session(&address).await?;
8604        }
8605        self.remove_managed_session_record(record_id, &record.workspace_id).await
8606    }
8607
8608    /// Removes one managed session record from durable state and notifies
8609    /// every listener -- the shared transaction body behind both
8610    /// `forget_session_record`'s operator-driven path (busy-checked above
8611    /// it, and clears any still-live session binding before calling in
8612    /// here) and the retention sweep (`retire_unavailable_session_records`,
8613    /// which only ever selects `ManagedSessionState::Unavailable` records
8614    /// that are already known never-busy and never bound, so it calls
8615    /// straight into this with neither check). Cleans a managed-worktree
8616    /// lease if the record held one, removes the record's environment
8617    /// materialization (or, absent one, removes it from memory directly),
8618    /// persists under `state_transaction` with rollback on persist failure,
8619    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8620    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8621    /// exactly as it does for an operator-driven forget.
8622    async fn remove_managed_session_record(
8623        &self,
8624        record_id: &SessionRecordId,
8625        workspace_id: &WorkspaceId,
8626    ) -> Result<(), NodeFailure> {
8627        let managed_lease_id = self.managed_worktrees.lock()
8628            .unwrap_or_else(|poisoned| poisoned.into_inner())
8629            .lease_for_workspace(workspace_id);
8630        if !self.destroy_record_materialization(record_id)? {
8631            let transaction = self
8632                .state_transaction
8633                .lock()
8634                .unwrap_or_else(|poisoned| poisoned.into_inner());
8635            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8636                failure(
8637                    NodeFailureCode::UnknownSessionRecord,
8638                    "managed session record does not exist",
8639                )
8640            })?;
8641            if let Err(error) = self.persist_state_locked() {
8642                let _ = self.insert_record(removed);
8643                return Err(persistence_failure(error));
8644            }
8645            drop(transaction);
8646        }
8647        self.publish(NodeEvent::SessionRecordRemoved {
8648            record_id: record_id.clone(),
8649        });
8650        if let Some(lease_id) = managed_lease_id {
8651            if let Some(snapshot) = self.managed_worktrees.lock()
8652                .unwrap_or_else(|poisoned| poisoned.into_inner())
8653                .get(&lease_id)
8654                .map(ManagedWorktreeLeaseRecord::snapshot)
8655            {
8656                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8657            }
8658            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8659        }
8660        Ok(())
8661    }
8662
8663    /// Retires `ManagedSessionState::Unavailable` records against the
8664    /// node's configured `session_record_retention` age/keep-N policy.
8665    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8666    /// `Dormant` is resumable and folding it into this sweep would delete
8667    /// records a caller can still act on. Both policy fields default to
8668    /// `0` (disabled); when both are `0` this is a no-op. When both are
8669    /// set, a record is retired only if it fails BOTH tests (older than
8670    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8671    /// workspace); when only one is set, that one alone decides. Called
8672    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8673    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8674    /// loop -- see both call sites.
8675    async fn retire_unavailable_session_records(&self) {
8676        let policy = self.session_record_retention;
8677        let age_enabled = policy.age_ms > 0;
8678        let keep_enabled = policy.keep_per_workspace > 0;
8679        if !age_enabled && !keep_enabled {
8680            return;
8681        }
8682        let now = unix_time_ms();
8683        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8684            self.session_records
8685                .lock()
8686                .unwrap_or_else(|poisoned| poisoned.into_inner())
8687                .records
8688                .values()
8689                .filter(|record| record.state == ManagedSessionState::Unavailable)
8690                .map(|record| {
8691                    (
8692                        record.record_id.clone(),
8693                        record.workspace_id.clone(),
8694                        record.provider.clone(),
8695                        record.updated_at_unix_ms,
8696                    )
8697                })
8698                .collect()
8699        };
8700        if candidates.is_empty() {
8701            return;
8702        }
8703        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8704            BTreeMap::new();
8705        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8706            by_workspace
8707                .entry(workspace_id)
8708                .or_default()
8709                .push((record_id, provider, updated_at_unix_ms));
8710        }
8711        let reason = if age_enabled && keep_enabled {
8712            "age+keep-n"
8713        } else if age_enabled {
8714            "age"
8715        } else {
8716            "keep-n"
8717        };
8718        let mut retired: u64 = 0;
8719        for (workspace_id, mut records) in by_workspace {
8720            // Newest `updated_at_unix_ms` first, so index `>=
8721            // keep_per_workspace` names exactly "beyond the newest N".
8722            records.sort_by(|left, right| right.2.cmp(&left.2));
8723            for (index, (record_id, provider, updated_at_unix_ms)) in
8724                records.into_iter().enumerate()
8725            {
8726                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8727                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8728                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8729                let should_retire = if age_enabled && keep_enabled {
8730                    age_hit && keep_hit
8731                } else {
8732                    age_hit || keep_hit
8733                };
8734                if !should_retire {
8735                    continue;
8736                }
8737                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8738                    Ok(()) => {
8739                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8740                        retired += 1;
8741                        tracing::info!(
8742                            node_id = %self.node_id,
8743                            record_id = %record_id,
8744                            workspace_id = %workspace_id,
8745                            provider = %provider,
8746                            age_ms = record_age_ms,
8747                            reason,
8748                            "managed session record retired"
8749                        );
8750                    }
8751                    Err(error) => tracing::warn!(
8752                        node_id = %self.node_id,
8753                        record_id = %record_id,
8754                        workspace_id = %workspace_id,
8755                        code = ?error.code,
8756                        cause = %error.message,
8757                        "session record retention sweep failed to retire a record"
8758                    ),
8759                }
8760            }
8761        }
8762        if retired > 0 {
8763            let remaining_unavailable = self
8764                .session_records
8765                .lock()
8766                .unwrap_or_else(|poisoned| poisoned.into_inner())
8767                .records
8768                .values()
8769                .filter(|record| record.state == ManagedSessionState::Unavailable)
8770                .count();
8771            tracing::info!(
8772                node_id = %self.node_id,
8773                retired,
8774                remaining_unavailable,
8775                "session record retention sweep completed"
8776            );
8777        }
8778    }
8779
8780    async fn resume_session_record(
8781        &self,
8782        record_id: &SessionRecordId,
8783        terminal_size: gate4agent_types::TerminalSize,
8784        initial_prompt: Option<String>,
8785    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8786        let mut record = self.record(record_id)?;
8787        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8788        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8789        if record.mode == SessionMode::Acp
8790            && record.provider.as_str() == "claude"
8791            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8792        {
8793            record.mode = SessionMode::Pty;
8794            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8795                r.mode = SessionMode::Pty;
8796            }
8797            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8798        }
8799        if record.active_session.is_some()
8800            || matches!(
8801                record.state,
8802                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8803            )
8804        {
8805            return Err(failure(
8806                NodeFailureCode::SessionRecordBusy,
8807                "managed session already has a live runtime binding",
8808            ));
8809        }
8810        let identity = record.provider_session.clone().ok_or_else(|| {
8811            failure(
8812                NodeFailureCode::SessionRecordNotResumable,
8813                "managed session has no verified provider session identity",
8814            )
8815        })?;
8816        let working_directory = self.workspace_root(&record.workspace_id)?;
8817        if !platform::roots_equal(
8818            &working_directory,
8819            windows_path_text(&record.canonical_root),
8820        ) {
8821            return Err(failure(
8822                NodeFailureCode::SessionRecordConflict,
8823                "managed session workspace root changed; refusing to resume in another directory",
8824            ));
8825        }
8826        {
8827            let records = self
8828                .session_records
8829                .lock()
8830                .unwrap_or_else(|poisoned| poisoned.into_inner());
8831            if records.records.values().any(|candidate| {
8832                candidate.record_id != *record_id
8833                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8834                        session_registry::same_provider_session(
8835                            &candidate.provider,
8836                            candidate_identity,
8837                            &record.provider,
8838                            &identity,
8839                        )
8840                    })
8841                    && (candidate.active_session.is_some()
8842                        || matches!(
8843                            candidate.state,
8844                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8845                        ))
8846            }) {
8847                return Err(failure(
8848                    NodeFailureCode::SessionRecordConflict,
8849                    "provider session is already active through another managed record",
8850                ));
8851            }
8852        }
8853        let runtime_requirement = if initial_prompt.is_some() {
8854            ProviderRuntimeRequirement::ResumeWithPrompt
8855        } else {
8856            ProviderRuntimeRequirement::Resume
8857        };
8858        let runtime_policy = self.admit_provider_runtime(
8859            &record.provider,
8860            runtime_requirement,
8861        )
8862        .await?;
8863        // The resumed runtime runs in the session folder its record was named after.
8864        let working_directory = match self.m4a_accounts.as_ref() {
8865            Some(_) => {
8866                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8867                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8868            }
8869            None => working_directory,
8870        };
8871        let request = ResumeLaunchRequest {
8872            working_directory,
8873            terminal_size,
8874            initial_prompt,
8875        };
8876        request
8877            .validate()
8878            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8879        self.ensure_binding_capacity()?;
8880        let resolved_materialization = match self.resolve_record_materialization(
8881            record_id,
8882            &record.provider,
8883            record.mode,
8884            record.environment_profile.as_ref(),
8885            record.bundle.as_ref(),
8886            record.context.as_ref(),
8887        ) {
8888            Ok(materialization) => materialization,
8889            Err(error) => {
8890                let label = match error.code {
8891                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8892                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8893                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8894                    _ => "environment-profile-unavailable",
8895                };
8896                self.mark_record_error(record_id, label)?;
8897                return Err(error);
8898            }
8899        };
8900        let (materialization_id, environment_overlay) = match resolved_materialization {
8901            Some((id, overlay)) => (Some(id), overlay),
8902            None => (None, None),
8903        };
8904        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8905        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8906        let environment_overlay = {
8907            let mut args: Vec<OsString> = Vec::new();
8908            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8909                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8910                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8911                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8912            }
8913            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8914            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8915            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8916                if let Some(sel) = options.as_ref() {
8917                    args.push("--model".into()); args.push(sel.model.clone().into());
8918                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8919                        args.push("--effort".into()); args.push(effort.to_owned().into());
8920                    }
8921                }
8922            }
8923            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8924                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8925                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8926                        instance_id: instance_id.0,
8927                        provider: record.provider.as_str().to_owned(),
8928                        workspace_id: record.workspace_id.as_str().to_owned(),
8929                        provider_session_id: identity.id.clone(),
8930                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8931                        cwd: String::new(),
8932                        acp: record.mode == SessionMode::Acp,
8933                        options,
8934                        cols: 0,
8935                        rows: 0,
8936                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8937                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8938                    });
8939                }
8940            }
8941            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8942        };
8943        // The resumed runtime keeps the m4a account of the runtime it replaces.
8944        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8945            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8946                if let Some(old) = self.bound_address_for_record(record_id) {
8947                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8948                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8949                    }
8950                }
8951                let session_key = self.m4a_session_key(instance_id);
8952                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8953            }
8954            _ => environment_overlay,
8955        };
8956        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8957            instance_id,
8958            &record.provider,
8959            record.mode,
8960            record.environment_profile.as_ref(),
8961        ) {
8962            Ok(selection) => selection,
8963            Err(error) => {
8964                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8965                return Err(error);
8966            }
8967        };
8968        let mut instance_overlay = environment_overlay
8969            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8970            .transpose()?
8971            .flatten();
8972        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8973            self.remove_session(&stale_address).await?;
8974        }
8975        let address = SessionAddress {
8976            workspace_id: record.workspace_id.clone(),
8977            session: SessionKey {
8978                instance_id,
8979                generation: SessionGeneration(1),
8980            },
8981        };
8982        let transaction = self
8983            .state_transaction
8984            .lock()
8985            .unwrap_or_else(|poisoned| poisoned.into_inner());
8986        let current = self.record(record_id)?;
8987        if current.active_session.is_some()
8988            || matches!(
8989                current.state,
8990                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8991            )
8992        {
8993            return Err(failure(
8994                NodeFailureCode::SessionRecordBusy,
8995                "managed session became active while resume was being prepared",
8996            ));
8997        }
8998        self.bind_managed_session_with_materialization(
8999            &address,
9000            record_id.clone(),
9001            runtime_policy,
9002            record.environment_profile.clone(),
9003            record.bundle.clone(),
9004            record.context.clone(),
9005            materialization_id,
9006        );
9007        if let Some(selection) = environment_selection.take() {
9008            selection.retain();
9009        }
9010        if let Some(overlay) = instance_overlay.take() {
9011            overlay.retain();
9012        }
9013        drop(transaction);
9014
9015        let transport = match record.mode {
9016            SessionMode::Pty => TransportKind::Pty,
9017            SessionMode::Inline => TransportKind::Pipe,
9018            SessionMode::Acp => TransportKind::Acp,
9019        };
9020        let agent_id = record.provider.clone();
9021        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9022        if let Err(error) = self
9023            .dispatch_bounded(
9024                ControlCommand::Register {
9025                    instance_id,
9026                    agent_id,
9027                    transport,
9028                },
9029                dispatch_timeout,
9030            )
9031            .await
9032        {
9033            if let Some(binding) = self.remove_binding(&address) {
9034                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9035            }
9036            return Err(error);
9037        }
9038        let command = self.prepare_command(ControlCommand::Resume {
9039            instance_id,
9040            target: ResumeTarget::ProviderSession { identity },
9041            runtime_policy,
9042            request,
9043        });
9044        let started_after = self.current_sequence();
9045        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9046            self.rollback_spawn(&address, dispatch_timeout).await?;
9047            return Err(error);
9048        }
9049        if let Err(error) = self.dispatch_envelope(command) {
9050            self.clear_armed_resume(&address);
9051            self.rollback_spawn(&address, dispatch_timeout).await?;
9052            return Err(error);
9053        }
9054        match self
9055            .wait_for_record_resume(&address, started_after)
9056            .await
9057        {
9058            Ok(settled_address) => {
9059                let managed_lease_id = {
9060                    self.managed_worktrees.lock()
9061                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9062                        .lease_for_workspace(&record.workspace_id)
9063                };
9064                if let Some(lease_id) = managed_lease_id {
9065                    {
9066                        let mut bindings = self.session_bindings.lock()
9067                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9068                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9069                            .ok_or_else(|| failure(
9070                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071                                "resumed managed session lost its exact binding",
9072                            ))?;
9073                        if binding.generation != settled_address.session.generation {
9074                            return Err(failure(
9075                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9076                                "resumed managed session generation diverged",
9077                            ));
9078                        }
9079                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9080                    }
9081                    let snapshot = self.managed_worktrees.lock()
9082                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9083                        .bind_session(
9084                            &lease_id,
9085                            ManagedWorktreeSessionHolder {
9086                                incarnation_id: self.incarnation_id,
9087                                instance_id: settled_address.session.instance_id,
9088                                generation: settled_address.session.generation,
9089                            },
9090                            Some(record_id.clone()),
9091                            unix_time_ms(),
9092                        ).map_err(|message| failure(
9093                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9094                            &message,
9095                        ))?;
9096                    if let Err(error) = self.persist_state() {
9097                        let _ = self.remove_session(&settled_address).await;
9098                        return Err(persistence_failure(error));
9099                    }
9100                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9101                }
9102                Ok((self.record(record_id)?, settled_address))
9103            }
9104            Err(error) => {
9105                if matches!(
9106                    error.code,
9107                    NodeFailureCode::SessionRecordNotResumable
9108                        | NodeFailureCode::BackendOperationFailed
9109                ) {
9110                    if let Some(bound) = self.bound_address_for_record(record_id) {
9111                        let _ = self.remove_session(&bound).await;
9112                    }
9113                }
9114                Err(error)
9115            }
9116        }
9117    }
9118
9119    async fn wait_for_record_resume(
9120        &self,
9121        initial_address: &SessionAddress,
9122        after_sequence: u64,
9123    ) -> Result<SessionAddress, NodeFailure> {
9124        let deadline = Instant::now()
9125            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9126        let mut scan_after = after_sequence;
9127        loop {
9128            let events = self
9129                .history
9130                .lock()
9131                .unwrap_or_else(|poisoned| poisoned.into_inner())
9132                .events
9133                .iter()
9134                .filter(|event| event.sequence > scan_after)
9135                .cloned()
9136                .collect::<Vec<_>>();
9137            for envelope in events {
9138                scan_after = scan_after.max(envelope.sequence);
9139                let NodeEvent::Control { address, event } = envelope.event else {
9140                    continue;
9141                };
9142                if address.session.instance_id != initial_address.session.instance_id {
9143                    continue;
9144                }
9145                match event.event {
9146                    ControlEventKind::Resumed { .. } => return Ok(address),
9147                    ControlEventKind::ResumeDenied { reason } => {
9148                        return Err(failure(
9149                            NodeFailureCode::SessionRecordNotResumable,
9150                            &reason,
9151                        ));
9152                    }
9153                    ControlEventKind::ResumeFailed { message }
9154                    | ControlEventKind::CommandRejected { message } => {
9155                        return Err(failure(
9156                            NodeFailureCode::BackendOperationFailed,
9157                            &message,
9158                        ));
9159                    }
9160                    _ => {}
9161                }
9162            }
9163            if Instant::now() >= deadline {
9164                return Err(failure(
9165                    NodeFailureCode::BackendBusy,
9166                    "managed resume did not settle before the bounded deadline",
9167                ));
9168            }
9169            sleep(Duration::from_millis(5)).await;
9170        }
9171    }
9172
9173    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9174        self.workspaces
9175            .read()
9176            .unwrap_or_else(|poisoned| poisoned.into_inner())
9177            .get(workspace_id)
9178            .cloned()
9179            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9180    }
9181
9182    fn workspace_roots(&self) -> Vec<PathBuf> {
9183        self.workspaces
9184            .read()
9185            .unwrap_or_else(|poisoned| poisoned.into_inner())
9186            .values()
9187            .map(PathBuf::from)
9188            .collect()
9189    }
9190
9191    async fn inspect_workspace(
9192        &self,
9193        workspace_id: WorkspaceId,
9194    ) -> Result<WorkspaceInspection, NodeFailure> {
9195        let _permit = self
9196            .inspection_slots
9197            .clone()
9198            .try_acquire_owned()
9199            .map_err(|_| {
9200                tracing::warn!(
9201                    workspace_id = %workspace_id,
9202                    "workspace inspection rejected: inspection capacity is busy",
9203                );
9204                failure(
9205                    NodeFailureCode::BackendBusy,
9206                    "workspace inspection capacity is busy",
9207                )
9208            })?;
9209        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9210            tracing::warn!(
9211                workspace_id = %workspace_id,
9212                code = ?error.code,
9213                "workspace inspection rejected: workspace is not registered",
9214            );
9215            error
9216        })?;
9217        let tree_root = canonical_root.clone();
9218        let started_at = Instant::now();
9219        let time_budget_ms = workspace_inspection_time_budget_ms();
9220        let entry_cap = workspace_inspection_entry_cap();
9221        let deadline = started_at + Duration::from_millis(time_budget_ms);
9222        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9223            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9224        })
9225        .await
9226        .map_err(|error| {
9227            tracing::warn!(
9228                workspace_id = %workspace_id,
9229                error = %error,
9230                "workspace inspection rejected: walk task failed",
9231            );
9232            failure(
9233                NodeFailureCode::BackendOperationFailed,
9234                &format!("workspace inspection task failed: {error}"),
9235            )
9236        })?;
9237        let (mut git, git_time_budget_exceeded) =
9238            inspect_git_workspace(&canonical_root, deadline).await;
9239        if !git.worktrees.is_empty() {
9240            let registered = self
9241                .workspaces
9242                .read()
9243                .unwrap_or_else(|poisoned| poisoned.into_inner())
9244                .iter()
9245                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9246                .collect::<Vec<_>>();
9247            for worktree in &mut git.worktrees {
9248                worktree.workspace_id = registered
9249                    .iter()
9250                    .find(|(_, root)| {
9251                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9252                    })
9253                    .map(|(workspace_id, _)| workspace_id.clone());
9254            }
9255        }
9256        if git.is_repository {
9257            git.managed_worktree = self
9258                .managed_worktrees
9259                .lock()
9260                .unwrap_or_else(|poisoned| poisoned.into_inner())
9261                .git_scope(&workspace_id, git.branch.as_deref());
9262        }
9263        let truncation = workspace_inspection_truncation(
9264            &walk_budget,
9265            git_time_budget_exceeded,
9266            started_at.elapsed(),
9267        );
9268        if let Some(truncation) = &truncation {
9269            tracing::info!(
9270                workspace_id = %workspace_id,
9271                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9272                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9273                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9274                entries_visited = truncation.entries_visited,
9275                elapsed_ms = truncation.elapsed_ms,
9276                "workspace inspection truncated by its inner budget",
9277            );
9278        }
9279        Ok(WorkspaceInspection {
9280            workspace_id,
9281            entries,
9282            tree_truncated,
9283            git,
9284            truncation,
9285        })
9286    }
9287
9288    async fn browse_host_directories(
9289        &self,
9290        directory: Option<OpaqueHostPath>,
9291        after: Option<OpaqueHostPath>,
9292    ) -> Result<HostDirectoryListing, NodeFailure> {
9293        let permit = self
9294            .inspection_slots
9295            .clone()
9296            .try_acquire_owned()
9297            .map_err(|_| {
9298                failure(
9299                    NodeFailureCode::BackendBusy,
9300                    "host directory browse capacity is busy",
9301                )
9302            })?;
9303        let task = tokio::task::spawn_blocking(move || {
9304            let _permit = permit;
9305            browse_host_directories(directory, after)
9306        });
9307        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9308            .await
9309            .map_err(|_| {
9310                failure(
9311                    NodeFailureCode::HostDirectoryReadTimedOut,
9312                    "host directory browse exceeded its bounded deadline",
9313                )
9314            })?
9315            .map_err(|_| {
9316                failure(
9317                    NodeFailureCode::HostDirectoryReadFailed,
9318                    "host directory browse task failed",
9319                )
9320            })?
9321            .map_err(host_directory_failure)
9322    }
9323
9324    async fn read_workspace_file(
9325        &self,
9326        workspace_id: WorkspaceId,
9327        path: RepositoryPath,
9328    ) -> Result<WorkspaceFileRead, NodeFailure> {
9329        let canonical_root = self.workspace_root(&workspace_id)?;
9330        let permit = self
9331            .inspection_slots
9332            .clone()
9333            .try_acquire_owned()
9334            .map_err(|_| {
9335                failure(
9336                    NodeFailureCode::BackendBusy,
9337                    "workspace file read capacity is busy",
9338                )
9339            })?;
9340        let path_to_read = path.clone();
9341        let task = tokio::task::spawn_blocking(move || {
9342            let _permit = permit;
9343            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9344        });
9345        let content = timeout(
9346            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9347            task,
9348        )
9349        .await
9350        .map_err(|_| {
9351            failure(
9352                NodeFailureCode::RepositoryFileReadTimedOut,
9353                "repository file read exceeded its bounded deadline",
9354            )
9355        })?
9356        .map_err(|_| {
9357            failure(
9358                NodeFailureCode::RepositoryFileReadFailed,
9359                "repository file read task failed",
9360            )
9361        })?
9362        .map_err(workspace_file_failure)?;
9363        let content = match content {
9364            WorkspaceFileBytes::Utf8(text) => {
9365                let revision = Some(workspace_file_revision(text.as_bytes()));
9366                return Ok(WorkspaceFileRead {
9367                    workspace_id,
9368                    path,
9369                    content: WorkspaceFileContent::Utf8 {
9370                        byte_len: u32::try_from(text.len())
9371                            .expect("bounded workspace text length must fit u32"),
9372                        text,
9373                    },
9374                    revision,
9375                });
9376            }
9377            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9378                WorkspaceFileContent::NonUtf8 {
9379                    byte_len: u32::try_from(byte_length)
9380                        .expect("bounded workspace file length must fit u32"),
9381                }
9382            }
9383            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9384                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9385            },
9386        };
9387        Ok(WorkspaceFileRead {
9388            workspace_id,
9389            path,
9390            content,
9391            revision: None,
9392        })
9393    }
9394
9395    async fn write_workspace_file(
9396        &self,
9397        workspace_id: WorkspaceId,
9398        path: RepositoryPath,
9399        expected_revision: WorkspaceFileRevision,
9400        text: String,
9401    ) -> Result<WorkspaceFileRead, NodeFailure> {
9402        let canonical_root = self.workspace_root(&workspace_id)?;
9403        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9404            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9405        })?;
9406        let path_to_write = path.clone();
9407        let expected = expected_revision.as_str().to_owned();
9408        let text_to_write = text.clone();
9409        let task = tokio::task::spawn_blocking(move || {
9410            let _permit = permit;
9411            write_workspace_file_to_disk(
9412                Path::new(&canonical_root),
9413                &path_to_write,
9414                &expected,
9415                &text_to_write,
9416            )
9417        });
9418        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9419            .await
9420            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9421            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9422            .map_err(workspace_file_write_failure)?;
9423        Ok(WorkspaceFileRead {
9424            workspace_id,
9425            path,
9426            content: WorkspaceFileContent::Utf8 {
9427                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9428                text,
9429            },
9430            revision: Some(WorkspaceFileRevision::new(revision)
9431                .expect("workspace writer returns a valid SHA-256 revision")),
9432        })
9433    }
9434
9435    async fn create_workspace_file(
9436        &self,
9437        workspace_id: WorkspaceId,
9438        path: RepositoryPath,
9439    ) -> Result<WorkspaceFileRead, NodeFailure> {
9440        let canonical_root = self.workspace_root(&workspace_id)?;
9441        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9442            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9443        })?;
9444        let path_to_create = path.clone();
9445        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9446        let task_commit_state = Arc::clone(&commit_state);
9447        let task = tokio::task::spawn_blocking(move || {
9448            let _permit = permit;
9449            create_workspace_file_on_disk(
9450                Path::new(&canonical_root),
9451                &path_to_create,
9452                &task_commit_state,
9453            )
9454        });
9455        let revision = settle_workspace_entry_create(
9456            task,
9457            commit_state,
9458            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9459            "repository file create exceeded its bounded deadline",
9460            "repository file create task failed",
9461        )
9462        .await?;
9463        Ok(WorkspaceFileRead {
9464            workspace_id,
9465            path,
9466            content: WorkspaceFileContent::Utf8 {
9467                text: String::new(),
9468                byte_len: 0,
9469            },
9470            revision: Some(WorkspaceFileRevision::new(revision)
9471                .expect("workspace creator returns a valid SHA-256 revision")),
9472        })
9473    }
9474
9475    async fn create_workspace_directory(
9476        &self,
9477        workspace_id: WorkspaceId,
9478        path: RepositoryPath,
9479    ) -> Result<WorkspaceEntry, NodeFailure> {
9480        let canonical_root = self.workspace_root(&workspace_id)?;
9481        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9482            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9483        })?;
9484        let path_to_create = path.clone();
9485        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9486        let task_commit_state = Arc::clone(&commit_state);
9487        let task = tokio::task::spawn_blocking(move || {
9488            let _permit = permit;
9489            create_workspace_directory_on_disk(
9490                Path::new(&canonical_root),
9491                &path_to_create,
9492                &task_commit_state,
9493            )
9494        });
9495        settle_workspace_entry_create(
9496            task,
9497            commit_state,
9498            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9499            "repository directory create exceeded its bounded deadline",
9500            "repository directory create task failed",
9501        )
9502        .await?;
9503        Ok(WorkspaceEntry {
9504            relative_path: path,
9505            kind: WorkspaceEntryKind::Directory,
9506        })
9507    }
9508
9509    async fn read_git_history(
9510        &self,
9511        workspace_id: WorkspaceId,
9512        path: Option<RepositoryPath>,
9513        before: Option<GitObjectId>,
9514        limit: u16,
9515    ) -> Result<GitHistoryPage, NodeFailure> {
9516        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9517            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9518        })?;
9519        let root = self.workspace_root(&workspace_id)?;
9520        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9521    }
9522
9523    async fn read_git_diff(
9524        &self,
9525        workspace_id: WorkspaceId,
9526        request: GitDiffRequest,
9527    ) -> Result<GitDiff, NodeFailure> {
9528        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9529            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9530        })?;
9531        let root = self.workspace_root(&workspace_id)?;
9532        read_git_diff_bounded(&root, request).await
9533    }
9534
9535    fn native_session_route_root(
9536        &self,
9537        route: &NativeSessionCatalogRoute,
9538    ) -> Result<Option<String>, NodeFailure> {
9539        route
9540            .validate()
9541            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9542        match route.scope {
9543            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9544                .workspace_id
9545                .as_ref()
9546                .map(|workspace_id| self.workspace_root(workspace_id))
9547                .transpose(),
9548            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9549        }
9550    }
9551
9552    fn project_native_session_entries(
9553        &self,
9554        route: &NativeSessionCatalogRoute,
9555        entries: Vec<ScopedNativeSessionCatalogEntry>,
9556    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9557        let current_root = route
9558            .workspace_id
9559            .as_ref()
9560            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9561        let records = self
9562            .session_records
9563            .lock()
9564            .unwrap_or_else(|poisoned| poisoned.into_inner());
9565        let mut seen_records = std::collections::HashSet::new();
9566        entries
9567            .into_iter()
9568            .map(|entry| {
9569                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9570                    let matches = records
9571                        .records
9572                        .values()
9573                        .filter(|record| {
9574                            record.provider == route.provider
9575                                && record.workspace_id == *workspace_id
9576                                && current_root.as_deref().is_some_and(|root| {
9577                                    platform::roots_equal(
9578                                        root,
9579                                        windows_path_text(&record.canonical_root),
9580                                    )
9581                                })
9582                                && record.provider_session.as_ref().is_some_and(|identity| {
9583                                    session_registry::same_provider_session(
9584                                        &record.provider,
9585                                        identity,
9586                                        &route.provider,
9587                                        &entry.provider_session,
9588                                    )
9589                                })
9590                        })
9591                        .map(|record| record.record_id.clone())
9592                        .collect::<Vec<_>>();
9593                    (matches.len() == 1
9594                        && seen_records.insert(matches[0].clone()))
9595                        .then(|| matches[0].clone())
9596                });
9597                let metadata = entry.metadata;
9598                crate::protocol::NativeSessionCatalogEntry {
9599                    selection_id: metadata.selection_id,
9600                    title: metadata.title,
9601                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9602                    model: metadata.model,
9603                    message_count: metadata.message_count,
9604                    completed_turn_count: metadata.completed_turn_count,
9605                    external_group: entry.external_group,
9606                    record_id,
9607                }
9608            })
9609            .collect()
9610    }
9611
9612    async fn catalog_native_sessions(
9613        &self,
9614        route: NativeSessionCatalogRoute,
9615        limit: u16,
9616    ) -> Result<(
9617        Vec<crate::protocol::NativeSessionCatalogEntry>,
9618        crate::protocol::NativeSessionCatalogSummary,
9619    ), NodeFailure> {
9620        let canonical_root = self.native_session_route_root(&route)?;
9621        let registered_workspace_roots = self.workspace_roots();
9622        let provider = route.provider.clone();
9623        let scope = route.scope;
9624        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9625            failure(
9626                NodeFailureCode::InvalidRequest,
9627                "native session catalog is not configured",
9628            )
9629        })?;
9630        let _permit = Arc::clone(&self.inspection_slots)
9631            .try_acquire_owned()
9632            .map_err(|_| {
9633                failure(
9634                    NodeFailureCode::BackendBusy,
9635                    "native session catalog capacity is busy",
9636                )
9637            })?;
9638        let page = run_native_session_catalog_operation(
9639            catalog,
9640            "native session catalog",
9641            move |catalog| {
9642                catalog.catalog_initial_for_scope(
9643                    &provider,
9644                    scope,
9645                    canonical_root.as_deref().map(Path::new),
9646                    &registered_workspace_roots,
9647                    limit,
9648                )
9649            },
9650        )
9651        .await?;
9652        let summary = crate::protocol::NativeSessionCatalogSummary {
9653            catalog_revision: page.revision,
9654            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9655            recent_total_count: page.recent_total_count,
9656            older_total_count: page.older_total_count,
9657            recent_next_after_selection_id: page.next_after_selection_id,
9658            recent_has_more: page.remaining_count > 0,
9659        };
9660        Ok((self.project_native_session_entries(&route, page.entries), summary))
9661    }
9662
9663    async fn page_native_sessions(
9664        &self,
9665        route: NativeSessionCatalogRoute,
9666        window: crate::protocol::NativeSessionCatalogWindow,
9667        catalog_revision: u64,
9668        recent_cutoff_unix_ms: u64,
9669        after_selection_id: Option<String>,
9670        limit: u16,
9671    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9672        let canonical_root = self.native_session_route_root(&route)?;
9673        let registered_workspace_roots = self.workspace_roots();
9674        let provider = route.provider.clone();
9675        let scope = route.scope;
9676        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9677            failure(
9678                NodeFailureCode::InvalidRequest,
9679                "native session catalog is not configured",
9680            )
9681        })?;
9682        let _permit = Arc::clone(&self.inspection_slots)
9683            .try_acquire_owned()
9684            .map_err(|_| {
9685                failure(
9686                    NodeFailureCode::BackendBusy,
9687                    "native session catalog capacity is busy",
9688                )
9689            })?;
9690        let page = run_native_session_catalog_operation(
9691            catalog,
9692            "native session catalog page",
9693            move |catalog| {
9694                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9695                    &provider,
9696                    scope,
9697                    canonical_root.as_deref().map(Path::new),
9698                    &registered_workspace_roots,
9699                    window,
9700                    catalog_revision,
9701                    recent_cutoff_unix_ms,
9702                    after_selection_id.as_deref(),
9703                    limit,
9704                ))
9705            },
9706        )
9707        .await?;
9708        let page = page.map_err(|error| match error {
9709            NativeSessionCatalogError::StaleCatalog => failure(
9710                NodeFailureCode::StaleNativeSessionCatalog,
9711                "native session catalog revision or cursor is stale",
9712            ),
9713            _ => failure(
9714                NodeFailureCode::BackendOperationFailed,
9715                "native session catalog page failed",
9716            ),
9717        })?;
9718        let entries = self.project_native_session_entries(&route, page.entries);
9719        Ok(crate::protocol::NativeSessionCatalogPage {
9720            window: page.window,
9721            revision: page.revision,
9722            entries,
9723            next_after_selection_id: page.next_after_selection_id,
9724            remaining_count: page.remaining_count,
9725            has_more: page.remaining_count > 0,
9726        })
9727    }
9728
9729    async fn preview_native_session(
9730        &self,
9731        selection: NativeSessionSelection,
9732        message_limit: u16,
9733    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9734        selection
9735            .validate()
9736            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9737        let canonical_root = self.native_session_route_root(&selection.route)?;
9738        let registered_workspace_roots = self.workspace_roots();
9739        let provider = selection.route.provider.clone();
9740        let scope = selection.route.scope;
9741        let catalog_revision = selection.catalog_revision;
9742        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9743        let selection_id = selection.selection_id.clone();
9744        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9745            failure(
9746                NodeFailureCode::InvalidRequest,
9747                "native session preview is not configured",
9748            )
9749        })?;
9750        let _permit = Arc::clone(&self.inspection_slots)
9751            .try_acquire_owned()
9752            .map_err(|_| {
9753                failure(
9754                    NodeFailureCode::BackendBusy,
9755                    "native session preview capacity is busy",
9756                )
9757            })?;
9758        let preview = run_native_session_catalog_operation(
9759            catalog,
9760            "native session preview",
9761            move |catalog| {
9762                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9763                    &provider,
9764                    scope,
9765                    canonical_root.as_deref().map(Path::new),
9766                    &registered_workspace_roots,
9767                    catalog_revision,
9768                    recent_cutoff_unix_ms,
9769                    &selection_id,
9770                    message_limit,
9771                ))
9772            },
9773        )
9774        .await?;
9775        preview
9776            .map(Into::into)
9777            .map_err(native_session_preview_failure)
9778    }
9779
9780    async fn index_native_session(
9781        &self,
9782        selection: NativeSessionSelection,
9783        display_name: String,
9784    ) -> Result<ManagedSessionRecord, NodeFailure> {
9785        selection
9786            .validate()
9787            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9788        if selection.route.scope
9789            != gate4agent_types::NativeSessionCatalogScope::Workspace
9790        {
9791            return Err(failure(
9792                NodeFailureCode::WorkspaceRegistrationRequired,
9793                "register the external project as a workspace before indexing or resuming it",
9794            ));
9795        }
9796        let workspace_id = selection
9797            .route
9798            .workspace_id
9799            .clone()
9800            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9801        let canonical_root = self
9802            .native_session_route_root(&selection.route)?
9803            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9804        let registered_workspace_roots = self.workspace_roots();
9805        let provider = selection.route.provider.clone();
9806        let catalog_revision = selection.catalog_revision;
9807        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9808        let selection_id = selection.selection_id.clone();
9809        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9810            failure(
9811                NodeFailureCode::InvalidRequest,
9812                "native session index is not configured",
9813            )
9814        })?;
9815        let _permit = Arc::clone(&self.inspection_slots)
9816            .try_acquire_owned()
9817            .map_err(|_| {
9818                failure(
9819                    NodeFailureCode::BackendBusy,
9820                    "native session index capacity is busy",
9821                )
9822            })?;
9823        let resolved = run_native_session_catalog_operation(
9824            catalog,
9825            "native session index resolution",
9826            move |catalog| {
9827                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9828                    &provider,
9829                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9830                    Some(Path::new(&canonical_root)),
9831                    &registered_workspace_roots,
9832                    catalog_revision,
9833                    recent_cutoff_unix_ms,
9834                    &selection_id,
9835                ))
9836            },
9837        )
9838        .await?
9839        .map_err(native_session_preview_failure)?;
9840        self.index_provider_session_with_policy(
9841            workspace_id,
9842            selection.route.provider,
9843            resolved.identity,
9844            display_name,
9845            true,
9846        )
9847    }
9848
9849    async fn preview_native_session_by_session_id(
9850        &self,
9851        workspace_id: WorkspaceId,
9852        provider: AgentId,
9853        session_id: String,
9854        message_limit: u16,
9855    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9856        let canonical_root = self.workspace_root(&workspace_id)?;
9857        let registered_workspace_roots = self.workspace_roots();
9858        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9859            failure(
9860                NodeFailureCode::InvalidRequest,
9861                "native session preview is not configured",
9862            )
9863        })?;
9864        let _permit = Arc::clone(&self.inspection_slots)
9865            .try_acquire_owned()
9866            .map_err(|_| {
9867                failure(
9868                    NodeFailureCode::BackendBusy,
9869                    "native session preview capacity is busy",
9870                )
9871            })?;
9872        run_native_session_catalog_operation(
9873            catalog,
9874            "native session preview",
9875            move |catalog| {
9876                catalog.preview_session_id_for_workspace(
9877                    &provider,
9878                    Path::new(&canonical_root),
9879                    &registered_workspace_roots,
9880                    &session_id,
9881                    message_limit,
9882                )
9883            },
9884        )
9885        .await
9886    }
9887
9888    async fn register_workspace(
9889        &self,
9890        workspace_id: WorkspaceId,
9891        root: String,
9892    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9893        validate_workspace_request_root(&root)?;
9894        let canonicalize_workspace_id = workspace_id.clone();
9895        let workspace = tokio::task::spawn_blocking(move || {
9896            WorkspaceConfig::new(canonicalize_workspace_id, root)
9897        })
9898        .await
9899        .map_err(|error| {
9900            failure(
9901                NodeFailureCode::BackendOperationFailed,
9902                &format!("workspace canonicalization task failed: {error}"),
9903            )
9904        })?
9905        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9906        let transaction = self
9907            .state_transaction
9908            .lock()
9909            .unwrap_or_else(|poisoned| poisoned.into_inner());
9910        let mut workspaces = self
9911            .workspaces
9912            .write()
9913            .unwrap_or_else(|poisoned| poisoned.into_inner());
9914        if workspaces.contains_key(&workspace_id) {
9915            return Err(failure(
9916                NodeFailureCode::DuplicateWorkspaceId,
9917                "workspace ID is already registered",
9918            ));
9919        }
9920        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9921            platform::roots_equal(existing_root, workspace.canonical_root())
9922        }) {
9923            return Err(failure(
9924                NodeFailureCode::DuplicateWorkspaceRoot,
9925                &format!("workspace root is already registered as '{existing_id}'"),
9926            ));
9927        }
9928        {
9929            let records = self
9930                .session_records
9931                .lock()
9932                .unwrap_or_else(|poisoned| poisoned.into_inner());
9933            if let Some(record) = records.records.values().find(|record| {
9934                record.workspace_id == workspace_id
9935                    && !record
9936                        .canonical_root
9937                        .as_utf8()
9938                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9939            }) {
9940                return Err(failure(
9941                    NodeFailureCode::SessionRecordConflict,
9942                    &format!(
9943                        "workspace ID is retained by session record '{}' at another root",
9944                        record.record_id,
9945                    ),
9946                ));
9947            }
9948        }
9949        let snapshot = WorkspaceSnapshot {
9950            workspace_id: workspace_id.clone(),
9951            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9952            sessions: Vec::new(),
9953            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9954            managed_worktree_profiles: Some(WorktreeProfileInventory {
9955                profiles: Vec::new(),
9956            }),
9957        };
9958        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9959        drop(workspaces);
9960        let previous_records = {
9961            let mut records = self
9962                .session_records
9963                .lock()
9964                .unwrap_or_else(|poisoned| poisoned.into_inner());
9965            let previous = records.records.clone();
9966            for record in records.records.values_mut().filter(|record| {
9967                record.workspace_id == workspace_id
9968                    && record
9969                        .canonical_root
9970                        .as_utf8()
9971                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9972            }) {
9973                if record.active_session.is_none() {
9974                    record.state = detached_record_state(record);
9975                }
9976                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9977                    record.last_error = None;
9978                }
9979                record.updated_at_unix_ms = unix_time_ms();
9980            }
9981            previous
9982        };
9983        if let Err(error) = self.persist_state_locked() {
9984            self.workspaces
9985                .write()
9986                .unwrap_or_else(|poisoned| poisoned.into_inner())
9987                .remove(&workspace_id);
9988            self.session_records
9989                .lock()
9990                .unwrap_or_else(|poisoned| poisoned.into_inner())
9991                .records = previous_records;
9992            return Err(persistence_failure(error));
9993        }
9994        let restored = self
9995            .session_records
9996            .lock()
9997            .unwrap_or_else(|poisoned| poisoned.into_inner())
9998            .records
9999            .values()
10000            .filter(|record| record.workspace_id == workspace_id)
10001            .cloned()
10002            .collect::<Vec<_>>();
10003        drop(transaction);
10004        self.publish(NodeEvent::WorkspaceAdded {
10005            workspace: snapshot.clone(),
10006        });
10007        for record in restored {
10008            self.publish_record(record);
10009        }
10010        Ok(snapshot)
10011    }
10012
10013    async fn create_standalone_workspace(
10014        &self,
10015        workspace_id: WorkspaceId,
10016        root: String,
10017        initial_branch: Option<String>,
10018    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10019        validate_workspace_request_root(&root)?;
10020        validate_git_revision("initial branch", initial_branch.as_deref())?;
10021        let preflight_workspace_id = workspace_id.clone();
10022        let preflight_root = root.clone();
10023        let canonical_candidate = tokio::task::spawn_blocking(move || {
10024            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10025        })
10026        .await
10027        .map_err(|_| failure(
10028            NodeFailureCode::BackendOperationFailed,
10029            "standalone workspace root preflight task failed",
10030        ))?
10031        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10032        {
10033            let workspaces = self
10034                .workspaces
10035                .read()
10036                .unwrap_or_else(|poisoned| poisoned.into_inner());
10037            if workspaces.contains_key(&workspace_id) {
10038                return Err(failure(
10039                    NodeFailureCode::DuplicateWorkspaceId,
10040                    "workspace ID is already registered",
10041                ));
10042            }
10043            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10044                platform::roots_equal(existing_root, &canonical_candidate)
10045            }) {
10046                return Err(failure(
10047                    NodeFailureCode::DuplicateWorkspaceRoot,
10048                    &format!("workspace root is already registered as '{existing_id}'"),
10049                ));
10050            }
10051        }
10052        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10053            .await
10054            .map_err(standalone_workspace_failure)?;
10055        prepared
10056            .verify_for_registration()
10057            .await
10058            .map_err(standalone_workspace_failure)?;
10059        match self
10060            .register_workspace(workspace_id, prepared.root().to_owned())
10061            .await
10062        {
10063            Ok(workspace) => Ok(workspace),
10064            Err(registration_error) => {
10065                if prepared.compensate_registration_failure().await {
10066                    Err(registration_error)
10067                } else {
10068                    Err(failure(
10069                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10070                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10071                    ))
10072                }
10073            }
10074        }
10075    }
10076
10077    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10078        let bindings = self
10079            .session_bindings
10080            .lock()
10081            .unwrap_or_else(|poisoned| poisoned.into_inner());
10082        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10083            return Err(failure(
10084                NodeFailureCode::WorkspaceBusy,
10085                "workspace retains a bound session; remove every session before unregistering it",
10086            ));
10087        }
10088        drop(bindings);
10089        let transaction = self
10090            .state_transaction
10091            .lock()
10092            .unwrap_or_else(|poisoned| poisoned.into_inner());
10093        let mut workspaces = self
10094            .workspaces
10095            .write()
10096            .unwrap_or_else(|poisoned| poisoned.into_inner());
10097        if !workspaces.contains_key(workspace_id) {
10098            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10099        }
10100        if workspaces.len() == 1 {
10101            return Err(failure(
10102                NodeFailureCode::LastWorkspace,
10103                "node must retain at least one workspace",
10104            ));
10105        }
10106        let removed_root = workspaces
10107            .remove(workspace_id)
10108            .expect("workspace presence was validated");
10109        drop(workspaces);
10110        let previous_records = {
10111            let mut records = self
10112                .session_records
10113                .lock()
10114                .unwrap_or_else(|poisoned| poisoned.into_inner());
10115            let previous = records.records.clone();
10116            for record in records
10117                .records
10118                .values_mut()
10119                .filter(|record| &record.workspace_id == workspace_id)
10120            {
10121                record.active_session = None;
10122                record.state = ManagedSessionState::Unavailable;
10123                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10124                record.updated_at_unix_ms = unix_time_ms();
10125            }
10126            previous
10127        };
10128        if let Err(error) = self.persist_state_locked() {
10129            self.workspaces
10130                .write()
10131                .unwrap_or_else(|poisoned| poisoned.into_inner())
10132                .insert(workspace_id.clone(), removed_root);
10133            self.session_records
10134                .lock()
10135                .unwrap_or_else(|poisoned| poisoned.into_inner())
10136                .records = previous_records;
10137            return Err(persistence_failure(error));
10138        }
10139        let unavailable = self
10140            .session_records
10141            .lock()
10142            .unwrap_or_else(|poisoned| poisoned.into_inner())
10143            .records
10144            .values()
10145            .filter(|record| &record.workspace_id == workspace_id)
10146            .cloned()
10147            .collect::<Vec<_>>();
10148        drop(transaction);
10149        self.publish(NodeEvent::WorkspaceRemoved {
10150            workspace_id: workspace_id.clone(),
10151        });
10152        for record in unavailable {
10153            self.publish_record(record);
10154        }
10155        Ok(())
10156    }
10157
10158    async fn create_worktree(
10159        &self,
10160        source_workspace_id: WorkspaceId,
10161        workspace_id: WorkspaceId,
10162        target_root: String,
10163        branch: String,
10164        base: Option<String>,
10165    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10166        self.require_worktree_service(&source_workspace_id)?;
10167        validate_workspace_request_root(&target_root)?;
10168        validate_git_revision("worktree base", base.as_deref())?;
10169        let source_root = self.workspace_root(&source_workspace_id)?;
10170        {
10171            let workspaces = self
10172                .workspaces
10173                .read()
10174                .unwrap_or_else(|poisoned| poisoned.into_inner());
10175            if workspaces.contains_key(&workspace_id) {
10176                return Err(failure(
10177                    NodeFailureCode::DuplicateWorkspaceId,
10178                    "workspace ID is already registered",
10179                ));
10180            }
10181            if let Some((existing_id, _)) = workspaces
10182                .iter()
10183                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10184            {
10185                return Err(failure(
10186                    NodeFailureCode::DuplicateWorkspaceRoot,
10187                    &format!("workspace root is already registered as '{existing_id}'"),
10188                ));
10189            }
10190        }
10191        let mut worktree = create_git_worktree(
10192            &source_root,
10193            &target_root,
10194            &branch,
10195            base.as_deref(),
10196        )
10197        .await
10198        .map_err(git_worktree_failure)?;
10199        let workspace = match self
10200            .register_workspace(workspace_id, worktree.path.clone())
10201            .await
10202        {
10203            Ok(workspace) => workspace,
10204            Err(registration_error) => {
10205                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10206                    return Err(failure(
10207                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10208                        "worktree registration failed and compensation requires recovery",
10209                    ));
10210                }
10211                return Err(failure(
10212                    registration_error.code,
10213                    &registration_error.message,
10214                ));
10215            }
10216        };
10217        worktree.workspace_id = Some(workspace.workspace_id.clone());
10218        Ok((protocol_worktree(worktree), workspace))
10219    }
10220
10221    async fn remove_worktree(
10222        &self,
10223        source_workspace_id: WorkspaceId,
10224        target_root: String,
10225    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10226        self.require_worktree_service(&source_workspace_id)?;
10227        validate_workspace_request_root(&target_root)?;
10228        let source_root = self.workspace_root(&source_workspace_id)?;
10229        let target_root = normalize_worktree_removal_target(&target_root)
10230            .map_err(git_worktree_failure)?;
10231        let target = list_git_worktrees(&source_root)
10232            .await
10233            .map_err(git_worktree_failure)?
10234            .into_iter()
10235            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10236            .ok_or_else(|| {
10237                failure(
10238                    NodeFailureCode::WorktreeProtected,
10239                    "refusing to remove a path that is not in Git's worktree listing",
10240                )
10241            })?;
10242        let registered_workspace_id = {
10243            let workspaces = self
10244                .workspaces
10245                .read()
10246                .unwrap_or_else(|poisoned| poisoned.into_inner());
10247            let matched = workspaces
10248                .iter()
10249                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10250                .map(|(workspace_id, _)| workspace_id.clone());
10251            if matched.is_some() && workspaces.len() == 1 {
10252                return Err(failure(
10253                    NodeFailureCode::LastWorkspace,
10254                    "node must retain at least one workspace",
10255                ));
10256            }
10257            matched
10258        };
10259        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10260            let bindings = self
10261                .session_bindings
10262                .lock()
10263                .unwrap_or_else(|poisoned| poisoned.into_inner());
10264            if bindings
10265                .values()
10266                .any(|binding| &binding.workspace_id == workspace_id)
10267            {
10268                return Err(failure(
10269                    NodeFailureCode::WorkspaceBusy,
10270                    "worktree workspace retains a bound session; remove every session first",
10271                ));
10272            }
10273        }
10274        remove_git_worktree(&source_root, &target.path)
10275            .await
10276            .map_err(git_worktree_failure)?;
10277        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10278            self.unregister_workspace(workspace_id)
10279                .map_err(|error| failure(error.code, &error.message))?;
10280        }
10281        Ok(registered_workspace_id)
10282    }
10283
10284    async fn cleanup_managed_worktree(
10285        &self,
10286        lease_id: &ManagedWorktreeLeaseId,
10287        explicit: bool,
10288    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10289        let lease = self.managed_worktrees.lock()
10290            .unwrap_or_else(|poisoned| poisoned.into_inner())
10291            .get(lease_id)
10292            .cloned()
10293            .ok_or_else(|| failure(
10294                NodeFailureCode::UnknownManagedWorktreeLease,
10295                "managed worktree lease does not exist",
10296            ))?;
10297        if lease.state == ManagedWorktreeLeaseState::Removed {
10298            return Ok(lease.snapshot());
10299        }
10300        if lease.has_holders() {
10301            return Err(failure(
10302                NodeFailureCode::ManagedWorktreeBusy,
10303                "managed worktree lease still has a session or durable-record holder",
10304            ));
10305        }
10306        if self
10307            .materializations
10308            .lock()
10309            .unwrap_or_else(|poisoned| poisoned.into_inner())
10310            .values()
10311            .any(|record| record.managed_lease_id() == Some(lease_id))
10312        {
10313            return Err(failure(
10314                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10315                "managed worktree cleanup is blocked by session environment ownership",
10316            ));
10317        }
10318        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10319            let snapshot = {
10320                let mut registry = self.managed_worktrees.lock()
10321                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10322                let current = registry.get_mut(lease_id)
10323                    .expect("managed worktree lease remains present");
10324                current.state = ManagedWorktreeLeaseState::Retained;
10325                current.cleanup_failure = None;
10326                current.updated_at_unix_ms = unix_time_ms();
10327                current.snapshot()
10328            };
10329            if self.persist_state().is_err() {
10330                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10331            }
10332            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10333            return Ok(snapshot);
10334        }
10335        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10336        if profile.revision() != &lease.profile_revision {
10337            self.mark_managed_recovery_required(
10338                lease_id,
10339                ManagedWorktreeCleanupFailure::OwnershipConflict,
10340            );
10341            return Err(failure(
10342                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10343                "managed worktree profile revision changed",
10344            ));
10345        }
10346        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10347            self.mark_managed_recovery_required(
10348                lease_id,
10349                ManagedWorktreeCleanupFailure::OwnershipConflict,
10350            );
10351            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10352        })?;
10353        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10354        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10355            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10356            managed_git_worktree_failure(error)
10357        })?;
10358        let target = listed.iter()
10359            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10360        if let Some(target) = target {
10361            if target.is_main || target.is_bare || !exact_owned_worktree(
10362                &lease,
10363                &target.path,
10364                target.branch.as_deref(),
10365                &target.head,
10366            ) {
10367                self.mark_managed_recovery_required(
10368                    lease_id,
10369                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10370                );
10371                return Err(failure(
10372                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10373                    "Git worktree identity no longer matches the managed lease",
10374                ));
10375            }
10376            if target.prunable {
10377                self.mark_managed_cleanup_blocked(
10378                    lease_id,
10379                    ManagedWorktreeCleanupFailure::Prunable,
10380                );
10381                return Err(failure(
10382                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10383                    "managed worktree is prunable and requires recovery",
10384                ));
10385            }
10386            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10387                let failure_kind = match error.kind {
10388                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10389                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10390                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10391                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10392                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10393                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10394                };
10395                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10396                    self.mark_managed_recovery_required(lease_id, failure_kind);
10397                } else {
10398                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10399                }
10400                return Err(managed_git_worktree_failure(error));
10401            }
10402        } else {
10403            match std::fs::symlink_metadata(&lease.target_root) {
10404                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10405                Ok(_) => {
10406                    self.mark_managed_recovery_required(
10407                        lease_id,
10408                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10409                    );
10410                    return Err(failure(
10411                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10412                        "managed target exists but is not owned by Git",
10413                    ));
10414                }
10415                Err(_) => {
10416                    self.mark_managed_cleanup_blocked(
10417                        lease_id,
10418                        ManagedWorktreeCleanupFailure::Backend,
10419                    );
10420                    return Err(failure(
10421                        NodeFailureCode::BackendOperationFailed,
10422                        "managed target identity could not be inspected",
10423                    ));
10424                }
10425            }
10426        }
10427        let registered = self.workspaces.read()
10428            .unwrap_or_else(|poisoned| poisoned.into_inner())
10429            .contains_key(&lease.workspace_id);
10430        if registered {
10431            self.unregister_workspace(&lease.workspace_id)?;
10432        }
10433        let removed = {
10434            self.managed_worktrees.lock()
10435                .unwrap_or_else(|poisoned| poisoned.into_inner())
10436                .tombstone(lease_id, unix_time_ms())
10437                .expect("managed worktree lease remains present during cleanup")
10438        };
10439        self.persist_state().map_err(persistence_failure)?;
10440        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10441        Ok(removed.snapshot())
10442    }
10443
10444    fn mark_managed_cleanup_blocked(
10445        &self,
10446        lease_id: &ManagedWorktreeLeaseId,
10447        failure_kind: ManagedWorktreeCleanupFailure,
10448    ) {
10449        let snapshot = {
10450            let mut registry = self.managed_worktrees.lock()
10451                .unwrap_or_else(|poisoned| poisoned.into_inner());
10452            let Some(lease) = registry.get_mut(lease_id) else { return };
10453            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10454            lease.cleanup_failure = Some(failure_kind);
10455            lease.updated_at_unix_ms = unix_time_ms();
10456            lease.snapshot()
10457        };
10458        let _ = self.persist_state();
10459        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10460    }
10461
10462    fn require_worktree_service(
10463        &self,
10464        source_workspace_id: &WorkspaceId,
10465    ) -> Result<(), NodeFailure> {
10466        if self.managed_worktrees.lock()
10467            .unwrap_or_else(|poisoned| poisoned.into_inner())
10468            .lease_for_workspace(source_workspace_id)
10469            .is_some()
10470        {
10471            return Err(failure(
10472                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10473                "managed worktree targets are internal-only workspaces",
10474            ));
10475        }
10476        match self
10477            .worktree_service_modes
10478            .get(source_workspace_id)
10479            .copied()
10480            .unwrap_or(WorktreeServiceMode::Manual)
10481        {
10482            WorktreeServiceMode::Manual => Ok(()),
10483            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10484                NodeFailureCode::UnsupportedSpawnCapability,
10485                "Git worktree service mode is unavailable in this node build",
10486            )),
10487        }
10488    }
10489
10490    fn managed_reservation_conflict(
10491        &self,
10492        workspace_id: Option<&WorkspaceId>,
10493        root: Option<&str>,
10494    ) -> bool {
10495        self.managed_worktrees.lock()
10496            .unwrap_or_else(|poisoned| poisoned.into_inner())
10497            .active_records()
10498            .any(|lease| {
10499                workspace_id.is_some_and(|workspace_id| {
10500                    workspace_id == &lease.source_workspace_id
10501                        || workspace_id == &lease.workspace_id
10502                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10503            })
10504    }
10505
10506    fn reject_managed_reservation(
10507        &self,
10508        workspace_id: Option<&WorkspaceId>,
10509        root: Option<&str>,
10510    ) -> Result<(), NodeFailure> {
10511        if self.managed_reservation_conflict(workspace_id, root) {
10512            Err(failure(
10513                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10514                "managed worktree ownership may only be mutated through its lease cleanup",
10515            ))
10516        } else {
10517            Ok(())
10518        }
10519    }
10520
10521    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10522        self.validate_address(address)?;
10523        self.workspace_root(&address.workspace_id)
10524    }
10525
10526    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10527    /// `require_policy`'s own doc on those two requirements), so they keep
10528    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10529    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10530    /// `false` on the `require_policy` call below for them because a PTY
10531    /// resume never needs those facts, not because this method cannot look
10532    /// them up.
10533    ///
10534    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10535    /// the SAME `binding.runtime_policy` every other requirement reads here
10536    /// (one computed policy, read by every gate). That policy is no longer
10537    /// PTY-terminal-verification-shaped for an ACP session --
10538    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10539    /// `provider_runtime::policy_for_transport` -- so consulting it for
10540    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10541    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10542    /// even though a prompt is an ACP session's only interface). Pipe keeps
10543    /// reading the same PTY-probe-derived value it always has --
10544    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10545    /// its own doc for why).
10546    ///
10547    /// What this method still adds on top, for ACP/Pipe only, is a
10548    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10549    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10550    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10551    /// read off the same live `SessionSnapshot` state `require_acp_session`
10552    /// already reads for the ACP control verbs -- `TurnStarted` observed
10553    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10554    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10555    /// its fate in vendor-specific behaviour this node cannot observe (one
10556    /// agent may queue it, another drop it, another interleave it), so this
10557    /// refuses it outright rather than gambling on that -- a rule with no
10558    /// PTY analogue, which is why PTY does not take this extra precondition.
10559    fn require_session_runtime_policy(
10560        &self,
10561        address: &SessionAddress,
10562        requirement: ProviderRuntimeRequirement,
10563    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10564        let runtime_policy = {
10565            let bindings = self
10566                .session_bindings
10567                .lock()
10568                .unwrap_or_else(|poisoned| poisoned.into_inner());
10569            let binding = bindings
10570                .get(&address.session.instance_id)
10571                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10572            if binding.workspace_id != address.workspace_id {
10573                return Err(failure(
10574                    NodeFailureCode::SessionWorkspaceMismatch,
10575                    "session belongs to another workspace",
10576                ));
10577            }
10578            if binding.generation != address.session.generation {
10579                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10580            }
10581            binding.runtime_policy
10582        };
10583        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10584            let snapshot = self.internal_session_snapshot(address)?;
10585            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10586                && snapshot.provider.lead_activity != ProviderActivity::Idle
10587            {
10588                return Err(failure(
10589                    NodeFailureCode::TurnInFlight,
10590                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10591                ));
10592            }
10593        }
10594        require_policy(runtime_policy, false, false, requirement)
10595            .map(|()| runtime_policy)
10596            .map_err(|_| failure(
10597                NodeFailureCode::UnsupportedCapability,
10598                "session runtime policy does not admit this semantic operation",
10599            ))
10600    }
10601
10602    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10603        let mut bindings = self
10604            .session_bindings
10605            .lock()
10606            .unwrap_or_else(|poisoned| poisoned.into_inner());
10607        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10608            binding.workspace_id == address.workspace_id
10609                && binding.generation == address.session.generation
10610        }) {
10611            let removed = bindings.remove(&address.session.instance_id);
10612            self.clear_terminal_frame_watermark(address);
10613            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10614            return removed;
10615        }
10616        None
10617    }
10618
10619    fn cleanup_session_owned_materialization(
10620        &self,
10621        address: &SessionAddress,
10622        binding: &SessionBinding,
10623    ) -> Result<(), NodeFailure> {
10624        let Some(id) = binding.materialization_id.as_ref() else {
10625            return Ok(());
10626        };
10627        let owner = self
10628            .materializations
10629            .lock()
10630            .unwrap_or_else(|poisoned| poisoned.into_inner())
10631            .get(id)
10632            .map(|record| record.owner().clone());
10633        match owner {
10634            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10635            Some(MaterializationOwner::Session {
10636                incarnation_id,
10637                instance_id,
10638                generation,
10639            }) if incarnation_id == self.incarnation_id
10640                && instance_id == address.session.instance_id
10641                && generation == address.session.generation =>
10642            {
10643                self.cleanup_materialization(id)
10644            }
10645            Some(MaterializationOwner::Session { .. }) => Err(failure(
10646                NodeFailureCode::BackendOperationFailed,
10647                "session environment ownership requires recovery",
10648            )),
10649        }
10650    }
10651
10652    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10653        let mut watermarks = self
10654            .terminal_frame_watermarks
10655            .lock()
10656            .unwrap_or_else(|poisoned| poisoned.into_inner());
10657        if watermarks
10658            .get(&address.session.instance_id)
10659            .is_some_and(|(current, _)| current == address)
10660        {
10661            watermarks.remove(&address.session.instance_id);
10662        }
10663    }
10664
10665    #[cfg(test)]
10666    fn address_for(
10667        &self,
10668        instance_id: AgentInstanceId,
10669        generation: SessionGeneration,
10670    ) -> Option<SessionAddress> {
10671        let binding = self.session_bindings
10672            .lock()
10673            .unwrap_or_else(|poisoned| poisoned.into_inner())
10674            .get(&instance_id)
10675            .cloned()?;
10676        (binding.generation == generation).then_some(SessionAddress {
10677            workspace_id: binding.workspace_id,
10678            session: SessionKey { instance_id, generation },
10679        })
10680    }
10681
10682    fn arm_resume(
10683        &self,
10684        address: &SessionAddress,
10685        command_id: CommandId,
10686        runtime_policy: ProviderRuntimePolicy,
10687    ) -> Result<(), NodeFailure> {
10688        let mut bindings = self
10689            .session_bindings
10690            .lock()
10691            .unwrap_or_else(|poisoned| poisoned.into_inner());
10692        let binding = bindings
10693            .get_mut(&address.session.instance_id)
10694            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10695        if binding.workspace_id != address.workspace_id {
10696            return Err(failure(
10697                NodeFailureCode::SessionWorkspaceMismatch,
10698                "session belongs to another workspace",
10699            ));
10700        }
10701        if binding.generation != address.session.generation {
10702            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10703        }
10704        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10705        Ok(())
10706    }
10707
10708    fn clear_armed_resume(&self, address: &SessionAddress) {
10709        let mut bindings = self
10710            .session_bindings
10711            .lock()
10712            .unwrap_or_else(|poisoned| poisoned.into_inner());
10713        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10714            if binding.workspace_id == address.workspace_id
10715                && binding.generation == address.session.generation
10716            {
10717                binding.pending_resume = None;
10718            }
10719        }
10720    }
10721
10722    fn publish_control(&self, event: ControlEvent) {
10723        let revokes_harness_mcp = matches!(
10724            &event.event,
10725            ControlEventKind::Exited { .. }
10726                | ControlEventKind::Failed { .. }
10727                | ControlEventKind::Removed
10728        );
10729        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10730            let mut bindings = self
10731                .session_bindings
10732                .lock()
10733                .unwrap_or_else(|poisoned| poisoned.into_inner());
10734            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10735                return;
10736            };
10737            let previous_address = SessionAddress {
10738                workspace_id: binding.workspace_id.clone(),
10739                session: SessionKey {
10740                    instance_id: event.instance_id,
10741                    generation: binding.generation,
10742                },
10743            };
10744            let mut resume_command_rejected = false;
10745            if binding.generation == event.generation {
10746                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10747                    let rejected_resume = matches!(
10748                        &event.event,
10749                        ControlEventKind::CommandRejected { .. }
10750                    ) && event.command_id == Some(pending_command_id);
10751                    let resume_failed = matches!(
10752                        &event.event,
10753                        ControlEventKind::ResumeDenied { .. }
10754                            | ControlEventKind::ResumeFailed { .. }
10755                    );
10756                    if pending_generation == binding.generation
10757                        && (rejected_resume || resume_failed)
10758                    {
10759                        resume_command_rejected = rejected_resume;
10760                        binding.pending_resume = None;
10761                    }
10762                }
10763            } else {
10764                let expected = binding.generation.0.checked_add(1);
10765                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10766                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10767                    generation == binding.generation
10768                })
10769                    || expected != Some(event.generation.0)
10770                    || !authorized
10771                {
10772                    return;
10773                }
10774                let (_, _, runtime_policy) = binding
10775                    .pending_resume
10776                    .expect("authorized resume retains its pending runtime policy");
10777                binding.generation = event.generation;
10778                binding.runtime_policy = runtime_policy;
10779                binding.pending_resume = None;
10780            }
10781            (
10782                SessionAddress {
10783                    workspace_id: binding.workspace_id.clone(),
10784                    session: SessionKey {
10785                        instance_id: event.instance_id,
10786                        generation: event.generation,
10787                    },
10788                },
10789                (previous_address.session.generation != event.generation)
10790                    .then_some(previous_address),
10791                binding.record_id.clone(),
10792                binding.runtime_policy.provider_session_identity,
10793                resume_command_rejected,
10794            )
10795        };
10796        if let Some(previous_address) = previous_address.as_ref() {
10797            self.clear_terminal_frame_watermark(previous_address);
10798        }
10799        if let Some(record_id) = record_id {
10800            self.reconcile_managed_record(
10801                &record_id,
10802                &address,
10803                &event,
10804                identity_admitted,
10805                resume_command_rejected,
10806            );
10807        }
10808        // Every state change a session's process lifecycle can reach gets a
10809        // line here: this is the single chokepoint every `ControlEventKind`
10810        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10811        // came from a fresh spawn or a resume. Per-message provider chatter
10812        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10813        // matched here -- that is exactly the per-frame noise this crate
10814        // family's level discipline forbids at INFO.
10815        match &event.event {
10816            ControlEventKind::Running { process_id } => {
10817                if let Some(pid) = process_id {
10818                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10819                }
10820                #[cfg(unix)]
10821                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10822                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10823                }
10824                tracing::info!(
10825                    node_id = %self.node_id,
10826                    workspace_id = %address.workspace_id,
10827                    session = ?address.session,
10828                    pid = ?process_id,
10829                    "session process is running",
10830                );
10831            }
10832            ControlEventKind::Exited { exit_code, forced } => {
10833                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10834                #[cfg(unix)]
10835                if let Some(proxy) = self.signer_proxy.get().cloned() {
10836                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10837                    if proxy.has_session(&key) {
10838                        // Revoke at core off the event path (blocking uplink call).
10839                        std::thread::spawn(move || proxy.release(&key));
10840                    }
10841                }
10842                tracing::info!(
10843                    node_id = %self.node_id,
10844                    workspace_id = %address.workspace_id,
10845                    session = ?address.session,
10846                    exit_code = ?exit_code,
10847                    forced = forced,
10848                    "session process exited",
10849                );
10850            }
10851            ControlEventKind::Failed { message } => {
10852                tracing::warn!(
10853                    node_id = %self.node_id,
10854                    workspace_id = %address.workspace_id,
10855                    session = ?address.session,
10856                    cause = %message,
10857                    "session failed",
10858                );
10859            }
10860            ControlEventKind::CommandRejected { message } => {
10861                tracing::warn!(
10862                    node_id = %self.node_id,
10863                    workspace_id = %address.workspace_id,
10864                    session = ?address.session,
10865                    cause = %message,
10866                    "session command rejected",
10867                );
10868            }
10869            ControlEventKind::Removed => {
10870                tracing::info!(
10871                    node_id = %self.node_id,
10872                    workspace_id = %address.workspace_id,
10873                    session = ?address.session,
10874                    "session removed",
10875                );
10876            }
10877            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10878                tracing::info!(
10879                    node_id = %self.node_id,
10880                    workspace_id = %address.workspace_id,
10881                    session = ?address.session,
10882                    provider_session = ?provider_session,
10883                    "session resume authorized",
10884                );
10885            }
10886            ControlEventKind::Resumed { session: provider_session, process_id } => {
10887                tracing::info!(
10888                    node_id = %self.node_id,
10889                    workspace_id = %address.workspace_id,
10890                    session = ?address.session,
10891                    provider_session = ?provider_session,
10892                    pid = ?process_id,
10893                    "session resumed",
10894                );
10895            }
10896            ControlEventKind::ResumeDenied { reason } => {
10897                tracing::warn!(
10898                    node_id = %self.node_id,
10899                    workspace_id = %address.workspace_id,
10900                    session = ?address.session,
10901                    cause = %reason,
10902                    "session resume denied",
10903                );
10904            }
10905            ControlEventKind::ResumeFailed { message } => {
10906                tracing::warn!(
10907                    node_id = %self.node_id,
10908                    workspace_id = %address.workspace_id,
10909                    session = ?address.session,
10910                    cause = %message,
10911                    "session resume failed",
10912                );
10913            }
10914            _ => {}
10915        }
10916        self.apply_pending_acp_options(&address, &event);
10917        self.apply_acp_auto_permit(&address, &event);
10918        let stream_chunk = agent_stream_chunk(&event);
10919        self.publish(NodeEvent::Control {
10920            address: address.clone(),
10921            event,
10922        });
10923        if let Some(chunk) = stream_chunk {
10924            self.publish_agent_stream_chunk(address.clone(), chunk);
10925        }
10926        if revokes_harness_mcp {
10927            SessionLifecycleHook::on_session_ended(self, &address);
10928        }
10929    }
10930
10931    fn reconcile_managed_record(
10932        &self,
10933        record_id: &SessionRecordId,
10934        address: &SessionAddress,
10935        event: &ControlEvent,
10936        identity_admitted: bool,
10937        resume_command_rejected: bool,
10938    ) {
10939        let _transaction = self
10940            .state_transaction
10941            .lock()
10942            .unwrap_or_else(|poisoned| poisoned.into_inner());
10943        let observed_identity = match &event.event {
10944            ControlEventKind::ProviderEvent {
10945                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10946                ..
10947            } if identity_admitted => Some(identity.clone()),
10948            _ => None,
10949        };
10950        let identity_was_observed = observed_identity.is_some();
10951        let replacement_id = observed_identity
10952            .as_ref()
10953            .and_then(|_| self.allocate_record_id().ok());
10954        let mut upserts = Vec::new();
10955        let mut removals = Vec::new();
10956        let mut rebind = None;
10957        let original_records;
10958        let original_materializations = self
10959            .materializations
10960            .lock()
10961            .unwrap_or_else(|poisoned| poisoned.into_inner())
10962            .clone();
10963        {
10964            let mut records = self
10965                .session_records
10966                .lock()
10967                .unwrap_or_else(|poisoned| poisoned.into_inner());
10968            original_records = records.records.clone();
10969            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10970                return;
10971            };
10972            if let Some(identity) = observed_identity {
10973                let existing_id = records
10974                    .records
10975                    .values()
10976                    .find(|record| {
10977                        record.record_id != *record_id
10978                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10979                                session_registry::same_provider_session(
10980                                    &record.provider,
10981                                    record_identity,
10982                                    &current_snapshot.provider,
10983                                    &identity,
10984                                )
10985                            })
10986                    })
10987                    .map(|record| record.record_id.clone());
10988                if let Some(existing_id) = existing_id {
10989                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10990                        record.mode == current_snapshot.mode
10991                            && record.workspace_id == current_snapshot.workspace_id
10992                            && record.environment_profile == current_snapshot.environment_profile
10993                            && record.bundle == current_snapshot.bundle
10994                            && record.context_id == current_snapshot.context_id
10995                            && record.context == current_snapshot.context
10996                            && record
10997                                .canonical_root
10998                                .as_utf8()
10999                                .zip(current_snapshot.canonical_root.as_utf8())
11000                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11001                    });
11002                    if !scope_matches {
11003                        if let Some(current) = records.records.get_mut(record_id) {
11004                            current.state = ManagedSessionState::Unavailable;
11005                            current.last_error =
11006                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11007                            current.updated_at_unix_ms = unix_time_ms();
11008                            upserts.push(current.clone());
11009                        }
11010                    } else {
11011                        let existing_is_active = records
11012                            .records
11013                            .get(&existing_id)
11014                            .is_some_and(|record| record.active_session.is_some());
11015                        if existing_is_active {
11016                            if let Some(current) = records.records.get_mut(record_id) {
11017                                current.state = ManagedSessionState::Unavailable;
11018                                current.last_error =
11019                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11020                                current.updated_at_unix_ms = unix_time_ms();
11021                                upserts.push(current.clone());
11022                            }
11023                        } else {
11024                            if current_snapshot.provider_session.is_some() {
11025                                if let Some(current) = records.records.get_mut(record_id) {
11026                                    current.active_session = None;
11027                                    current.state = ManagedSessionState::Dormant;
11028                                    current.updated_at_unix_ms = unix_time_ms();
11029                                    upserts.push(current.clone());
11030                                }
11031                            } else {
11032                                records.records.remove(record_id);
11033                                removals.push(record_id.clone());
11034                            }
11035                            if let Some(existing) = records.records.get_mut(&existing_id) {
11036                                existing.provider_session = Some(identity.clone());
11037                                existing.active_session = Some(address.clone());
11038                                existing.state = ManagedSessionState::Live;
11039                                existing.updated_at_unix_ms = unix_time_ms();
11040                                existing.last_error = None;
11041                                upserts.push(existing.clone());
11042                            }
11043                            rebind = Some(existing_id);
11044                        }
11045                    }
11046                } else if current_snapshot
11047                    .provider_session
11048                    .as_ref()
11049                    .is_some_and(|current| {
11050                        !session_registry::same_provider_session(
11051                            &current_snapshot.provider,
11052                            current,
11053                            &current_snapshot.provider,
11054                            &identity,
11055                        )
11056                    })
11057                {
11058                    if let Some(current) = records.records.get_mut(record_id) {
11059                        current.active_session = None;
11060                        if current.bundle.is_some() || current.context.is_some() {
11061                            let had_bundle = current.bundle.is_some();
11062                            current.provider_session = None;
11063                            current.bundle = None;
11064                            current.context_id = None;
11065                            current.context = None;
11066                            current.state = ManagedSessionState::Unavailable;
11067                            current.last_error = Some(if had_bundle {
11068                                "bundle-unavailable".to_owned()
11069                            } else {
11070                                "context-unavailable".to_owned()
11071                            });
11072                        } else {
11073                            current.state = ManagedSessionState::Dormant;
11074                        }
11075                        current.updated_at_unix_ms = unix_time_ms();
11076                        upserts.push(current.clone());
11077                    }
11078                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11079                        if let Some(current) = records.records.get_mut(record_id) {
11080                            current.state = ManagedSessionState::Unavailable;
11081                            current.last_error =
11082                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11083                            current.updated_at_unix_ms = unix_time_ms();
11084                            upserts.push(current.clone());
11085                        }
11086                    } else if let Some(new_record_id) = replacement_id {
11087                        let now = unix_time_ms();
11088                        let replacement = ManagedSessionRecord {
11089                            record_id: new_record_id.clone(),
11090                            display_name: format!(
11091                                "{} #{}",
11092                                current_snapshot.provider.as_str(),
11093                                records
11094                                    .records
11095                                    .values()
11096                                    .filter(|record| {
11097                                        record.provider == current_snapshot.provider
11098                                    })
11099                                    .count()
11100                                    .saturating_add(1),
11101                            ),
11102                            provider: current_snapshot.provider.clone(),
11103                            mode: current_snapshot.mode,
11104                            state: ManagedSessionState::Live,
11105                            workspace_id: current_snapshot.workspace_id,
11106                            canonical_root: current_snapshot.canonical_root,
11107                            provider_session: Some(identity),
11108                            active_session: Some(address.clone()),
11109                            environment_profile: current_snapshot.environment_profile,
11110                            bundle: current_snapshot.bundle,
11111                            context_id: current_snapshot.context_id,
11112                            context: current_snapshot.context,
11113                            // This is a fresh record_id representing the session under its
11114                            // newly-observed identity; it has not yet produced any clean
11115                            // exit of its own, so it starts with no exported pack (the old
11116                            // record, still addressable under its own record_id, keeps
11117                            // whatever exported_context it already had).
11118                            exported_context: None,
11119                            task_binding: current_snapshot.task_binding,
11120                            created_at_unix_ms: now,
11121                            updated_at_unix_ms: now,
11122                            last_error: None,
11123                        };
11124                        records
11125                            .records
11126                            .insert(new_record_id.clone(), replacement.clone());
11127                        upserts.push(replacement);
11128                        rebind = Some(new_record_id);
11129                    } else if let Some(current) = records.records.get_mut(record_id) {
11130                        current.state = ManagedSessionState::Unavailable;
11131                        current.last_error =
11132                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11133                        upserts.push(current.clone());
11134                    }
11135                } else if let Some(current) = records.records.get_mut(record_id) {
11136                    current.provider_session = Some(identity);
11137                    current.active_session = Some(address.clone());
11138                    current.state = ManagedSessionState::Live;
11139                    current.updated_at_unix_ms = unix_time_ms();
11140                    current.last_error = None;
11141                    upserts.push(current.clone());
11142                }
11143            } else if let Some(current) = records.records.get_mut(record_id) {
11144                if resume_command_rejected {
11145                    current.active_session = None;
11146                    current.state = detached_record_state(current);
11147                    current.updated_at_unix_ms = unix_time_ms();
11148                    current.last_error =
11149                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11150                    upserts.push(current.clone());
11151                } else {
11152                    match &event.event {
11153                    ControlEventKind::Running { .. }
11154                    | ControlEventKind::ResumeAuthorized { .. }
11155                    | ControlEventKind::Resumed { .. } => {
11156                        current.active_session = Some(address.clone());
11157                        current.state = active_record_state(
11158                            identity_admitted,
11159                            current.provider_session.is_some(),
11160                        );
11161                        current.updated_at_unix_ms = unix_time_ms();
11162                        current.last_error = None;
11163                        upserts.push(current.clone());
11164                    }
11165                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11166                        current.active_session = None;
11167                        current.state = detached_record_state(current);
11168                        current.updated_at_unix_ms = unix_time_ms();
11169                        upserts.push(current.clone());
11170                    }
11171                    ControlEventKind::Failed { message }
11172                    | ControlEventKind::ResumeFailed { message } => {
11173                        current.active_session = None;
11174                        current.state = detached_record_state(current);
11175                        current.updated_at_unix_ms = unix_time_ms();
11176                        current.last_error = Some(
11177                            session_registry::sanitized_record_error_summary(message),
11178                        );
11179                        upserts.push(current.clone());
11180                    }
11181                    ControlEventKind::ResumeDenied { reason } => {
11182                        current.active_session = None;
11183                        current.state = detached_record_state(current);
11184                        current.updated_at_unix_ms = unix_time_ms();
11185                        current.last_error = Some(
11186                            session_registry::sanitized_record_error_summary(reason),
11187                        );
11188                        upserts.push(current.clone());
11189                    }
11190                        _ => {}
11191                    }
11192                }
11193            }
11194        }
11195        let materialization_owner_record = identity_was_observed.then(|| {
11196            rebind.clone().unwrap_or_else(|| record_id.clone())
11197        }).and_then(|candidate| {
11198            self.session_records
11199                .lock()
11200                .unwrap_or_else(|poisoned| poisoned.into_inner())
11201                .records
11202                .get(&candidate)
11203                .filter(|record| {
11204                    record.provider_session.is_some()
11205                        && record.active_session.as_ref() == Some(address)
11206                })
11207                .map(|_| candidate)
11208        });
11209        if let Some(owner_record_id) = materialization_owner_record {
11210            let materialization_id = self
11211                .session_bindings
11212                .lock()
11213                .unwrap_or_else(|poisoned| poisoned.into_inner())
11214                .get(&address.session.instance_id)
11215                .and_then(|binding| binding.materialization_id.clone());
11216            if let Some(materialization_id) = materialization_id {
11217                let mut transfer_failed = false;
11218                {
11219                    let mut materializations = self
11220                        .materializations
11221                        .lock()
11222                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11223                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11224                        match ownership.owner() {
11225                            MaterializationOwner::Session {
11226                                incarnation_id,
11227                                instance_id,
11228                                generation,
11229                            } if *incarnation_id == self.incarnation_id
11230                                && *instance_id == address.session.instance_id
11231                                && *generation == address.session.generation =>
11232                            {
11233                                transfer_failed = ownership
11234                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11235                                    .is_err();
11236                            }
11237                            MaterializationOwner::Record { record_id }
11238                                if record_id == &owner_record_id => {}
11239                            MaterializationOwner::Record { record_id } => {
11240                                let expected = record_id.clone();
11241                                transfer_failed = ownership
11242                                    .transfer_record_owner(
11243                                        &expected,
11244                                        owner_record_id.clone(),
11245                                        unix_time_ms(),
11246                                    )
11247                                    .is_err();
11248                            }
11249                            _ => transfer_failed = true,
11250                        }
11251                        if transfer_failed {
11252                            let _ = ownership.mark_recovery_required(unix_time_ms());
11253                        }
11254                    } else {
11255                        transfer_failed = true;
11256                    }
11257                }
11258                if transfer_failed {
11259                    if let Some(record) = self
11260                        .session_records
11261                        .lock()
11262                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11263                        .records
11264                        .get_mut(&owner_record_id)
11265                    {
11266                        record.state = ManagedSessionState::Unavailable;
11267                        record.last_error = Some("environment-profile-unavailable".to_owned());
11268                        record.updated_at_unix_ms = unix_time_ms();
11269                        upserts.push(record.clone());
11270                    }
11271                }
11272            }
11273        }
11274        if let Some(new_record_id) = rebind.clone() {
11275            if let Some(binding) = self
11276                .session_bindings
11277                .lock()
11278                .unwrap_or_else(|poisoned| poisoned.into_inner())
11279                .get_mut(&address.session.instance_id)
11280            {
11281                binding.record_id = Some(new_record_id);
11282            }
11283        }
11284        if upserts.is_empty() && removals.is_empty() {
11285            return;
11286        }
11287        if self.persist_state_locked().is_err() {
11288            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11289            let mut records = self
11290                .session_records
11291                .lock()
11292                .unwrap_or_else(|poisoned| poisoned.into_inner());
11293            records.records = original_records;
11294            *self
11295                .materializations
11296                .lock()
11297                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11298            if let Some(current) = records.records.get_mut(record_id) {
11299                current.state = ManagedSessionState::Unavailable;
11300                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11301                current.updated_at_unix_ms = unix_time_ms();
11302                upserts = vec![current.clone()];
11303            } else {
11304                upserts.clear();
11305            }
11306            removals.clear();
11307            drop(records);
11308            if let Some(binding) = self
11309                .session_bindings
11310                .lock()
11311                .unwrap_or_else(|poisoned| poisoned.into_inner())
11312                .get_mut(&address.session.instance_id)
11313            {
11314                binding.record_id = Some(record_id.clone());
11315            }
11316            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11317        }
11318        for record in upserts {
11319            self.publish_record(record);
11320        }
11321        for record_id in removals {
11322            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11323        }
11324    }
11325
11326    fn snapshot(&self) -> NodeSnapshot {
11327        let control = self.handle.snapshot();
11328        let bindings = self
11329            .session_bindings
11330            .lock()
11331            .unwrap_or_else(|poisoned| poisoned.into_inner())
11332            .clone();
11333        let workspace_roots = self
11334            .workspaces
11335            .read()
11336            .unwrap_or_else(|poisoned| poisoned.into_inner())
11337            .clone();
11338        let environment_profiles = self
11339            .environment_profiles
11340            .read()
11341            .unwrap_or_else(|poisoned| poisoned.into_inner());
11342        let mut workspaces = workspace_roots
11343            .iter()
11344            .map(|(workspace_id, canonical_root)| {
11345                (
11346                    workspace_id.clone(),
11347                    WorkspaceSnapshot {
11348                        workspace_id: workspace_id.clone(),
11349                        canonical_root: opaque_windows_path(canonical_root.clone()),
11350                        sessions: Vec::new(),
11351                        worktree_service_mode: Some(
11352                            self.worktree_service_modes
11353                                .get(workspace_id)
11354                                .copied()
11355                                .unwrap_or(WorktreeServiceMode::Manual),
11356                        ),
11357                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11358                            profiles: self
11359                                .managed_worktree_profiles
11360                                .get(workspace_id)
11361                                .into_iter()
11362                                .flat_map(BTreeMap::values)
11363                                .map(|profile| ManagedWorktreeProfileSummary {
11364                                    id: profile.profile_id().clone(),
11365                                    revision: profile.revision().clone(),
11366                                    retention: profile.retention(),
11367                                })
11368                                .collect(),
11369                        }),
11370                    },
11371                )
11372            })
11373            .collect::<BTreeMap<_, _>>();
11374        let mut agent_progress = Vec::new();
11375        for session in &control.sessions {
11376            let Some(binding) = bindings.get(&session.instance_id) else {
11377                continue;
11378            };
11379            if binding.generation != session.generation {
11380                continue;
11381            }
11382            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11383                let address = SessionAddress {
11384                    workspace_id: binding.workspace_id.clone(),
11385                    session: SessionKey {
11386                        instance_id: session.instance_id,
11387                        generation: session.generation,
11388                    },
11389                };
11390                if let Some(progress) =
11391                    agent_progress_from_provider_snapshot(address, &session.provider)
11392                {
11393                    agent_progress.push(progress);
11394                }
11395                workspace.sessions.push(session.clone());
11396            }
11397        }
11398        NodeSnapshot {
11399            node_id: self.node_id.clone(),
11400            enabled_providers: self.enabled_providers.clone(),
11401            provider_runtime_statuses: {
11402                let updates = self
11403                    .provider_runtime_status_updates
11404                    .read()
11405                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11406                ProviderRuntimeStatuses::new(
11407                    self.provider_runtime_statuses
11408                        .iter()
11409                        .filter(|status| !updates.contains_key(status.provider()))
11410                        .cloned()
11411                        .chain(updates.values().cloned()),
11412                )
11413                .expect("refreshed provider runtime inventory remains bounded and unique")
11414            },
11415            workspaces: workspaces.into_values().collect(),
11416            session_records: self
11417                .session_records
11418                .lock()
11419                .unwrap_or_else(|poisoned| poisoned.into_inner())
11420                .records
11421                .values()
11422                .cloned()
11423                .collect(),
11424            managed_worktrees: self.managed_worktrees
11425                .lock()
11426                .unwrap_or_else(|poisoned| poisoned.into_inner())
11427                .snapshots(),
11428            launch_inventory: Some(LaunchInventory {
11429                spawn_profiles: Some(
11430                    self.spawn_profiles
11431                        .iter()
11432                        .filter_map(|profile| {
11433                            resolved_spawn_profile_summary(profile, &environment_profiles)
11434                        })
11435                        .collect(),
11436                ),
11437                bundles: Some(
11438                    self.bundle_catalog
11439                        .read()
11440                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11441                        .iter()
11442                        .map(NodeBundle::receipt)
11443                        .collect(),
11444                ),
11445                // Opaque station allowlist ids from empty-default catalog /
11446                // optional startup loader. Never secrets. Dig2 probe stays stub.
11447                network_allowlists: {
11448                    let catalog = self
11449                        .network_allowlist_catalog
11450                        .read()
11451                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11452                    if catalog.is_empty() {
11453                        None
11454                    } else {
11455                        Some(catalog.ids().cloned().collect())
11456                    }
11457                },
11458            }),
11459            agent_progress,
11460        }
11461    }
11462
11463    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11464        assert!(
11465            !matches!(
11466                &event,
11467                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11468            ),
11469            "terminal frame and agent stream events must use their own dedicated live channel",
11470        );
11471        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11472        let sequence = history
11473            .last_sequence
11474            .checked_add(1)
11475            .expect("node event sequence exhausted");
11476        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11477            if let Some(evicted) = history.events.pop_front() {
11478                history.replay_floor_sequence = evicted
11479                    .sequence
11480                    .checked_add(1)
11481                    .expect("node replay floor sequence exhausted");
11482                history.removed_record_providers.remove(&evicted.sequence);
11483            }
11484        }
11485        match &event {
11486            NodeEvent::HarnessMcpReadCall { .. } => {
11487                panic!("transient harness MCP calls must not enter durable event history")
11488            }
11489            NodeEvent::SessionRecordUpserted { record } => {
11490                history
11491                    .record_providers
11492                    .insert(record.record_id.clone(), record.provider.clone());
11493            }
11494            NodeEvent::SessionRecordRemoved { record_id } => {
11495                if let Some(provider) = history.record_providers.remove(record_id) {
11496                    history.removed_record_providers.insert(sequence, provider);
11497                }
11498            }
11499            NodeEvent::ManagedWorktreeUpserted { .. }
11500            | NodeEvent::ManagedWorktreeRemoved { .. }
11501            | NodeEvent::Control { .. }
11502            | NodeEvent::ControllerChanged { .. }
11503            | NodeEvent::SessionRecordHistorySummarized { .. }
11504            | NodeEvent::WorkspaceAdded { .. }
11505            | NodeEvent::WorkspaceRemoved { .. }
11506            | NodeEvent::ResyncRequired { .. }
11507            | NodeEvent::AgentStream { .. }
11508            | NodeEvent::TerminalFrame { .. } => {}
11509        }
11510        let envelope = NodeEventEnvelope { sequence, event };
11511        history.events.push_back(envelope.clone());
11512        history.last_sequence = sequence;
11513        let _ = self.event_tx.send(envelope.clone());
11514        drop(history);
11515        envelope
11516    }
11517
11518    /// Publishes a transient (never durable) `NodeEvent` -- today only
11519    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11520    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11521    /// why this must never ride the shared `event_tx` bus). Returns whether
11522    /// the send reached at least one subscriber: `broadcast::Sender::send`
11523    /// fails in exactly one way, no active receiver at that instant, and
11524    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11525    /// return to fail the pending call immediately rather than leave it to
11526    /// expire silently at `LOCAL_READ_TIMEOUT`/
11527    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11528    fn publish_transient(&self, event: NodeEvent) -> bool {
11529        assert!(event.requires_harness_mcp_proxy_capability());
11530        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11531        let envelope = NodeEventEnvelope { sequence: 0, event };
11532        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11533            Ok(_) => true,
11534            Err(broadcast::error::SendError(undelivered)) => {
11535                if let Some(NodeEventEnvelope {
11536                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11537                    ..
11538                }) = undelivered.first() {
11539                    // `request.content_type` only, never a per-tool label --
11540                    // this crate does not decode `request.body` and does not
11541                    // hold `hatchery-harness-api` (Nested Control Plane
11542                    // doctrine, Law 3; see this crate's dependency on
11543                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11544                    tracing::warn!(
11545                        call_id = call_id.as_str(),
11546                        content_type = ?request.content_type,
11547                        cause = "no-subscribers",
11548                        "harness MCP proxy event could not be published",
11549                    );
11550                }
11551                false
11552            }
11553        }
11554    }
11555
11556    fn publish_terminal_frames(&self) {
11557        let snapshot = self.handle.snapshot();
11558        let bindings = self
11559            .session_bindings
11560            .lock()
11561            .unwrap_or_else(|poisoned| poisoned.into_inner());
11562        let candidates = snapshot
11563            .sessions
11564            .iter()
11565            .filter_map(|session| {
11566                let binding = bindings.get(&session.instance_id)?;
11567                if binding.generation != session.generation {
11568                    return None;
11569                }
11570                let frame = session.terminal_frame.clone()?;
11571                crate::session_signals::feed(
11572                    binding.workspace_id.as_str(),
11573                    session.instance_id.0,
11574                    session.agent_id.as_str(),
11575                    &frame.contents,
11576                );
11577                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11578                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11579                Some((
11580                    SessionAddress {
11581                        workspace_id: binding.workspace_id.clone(),
11582                        session: SessionKey {
11583                            instance_id: session.instance_id,
11584                            generation: session.generation,
11585                        },
11586                    },
11587                    frame,
11588                ))
11589            })
11590            .collect();
11591        drop(bindings);
11592        self.publish_terminal_frame_candidates(candidates);
11593    }
11594
11595    fn publish_terminal_frame_candidates(
11596        &self,
11597        candidates: Vec<(SessionAddress, TerminalFrame)>,
11598    ) {
11599        let mut watermarks = self
11600            .terminal_frame_watermarks
11601            .lock()
11602            .unwrap_or_else(|poisoned| poisoned.into_inner());
11603        let mut events = Vec::with_capacity(candidates.len());
11604        for (address, frame) in candidates {
11605            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11606            let advanced = watermarks
11607                .get(&address.session.instance_id)
11608                .map_or(true, |(current_address, sequence)| {
11609                    current_address != &address || frame.sequence > *sequence
11610                });
11611            if !advanced {
11612                continue;
11613            }
11614            if is_first_frame {
11615                // Do NOT sessionize on first PTY output: a session is a
11616                // provider conversation id, not bytes on the wire. Drafts
11617                // leave the provisional map only when HQ attaches
11618                // (Resize / TerminalBytes). The attach-never-arrived timer
11619                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11620                // disconnect grace starts only on a real client WS close.
11621                // This is the first PTY output this node has observed for
11622                // this session -- a one-time event per spawn, not per-frame
11623                // chatter (the watermark makes sure of that). A session that
11624                // reaches this line with no controller attached anywhere on
11625                // the node is running unobserved: exactly how processes from
11626                // repeated spawn attempts went orphaned for hours without a
11627                // single line about it.
11628                if self.controller_state().is_none() {
11629                    tracing::warn!(
11630                        node_id = %self.node_id,
11631                        workspace_id = %address.workspace_id,
11632                        session = ?address.session,
11633                        terminal_size = ?frame.size,
11634                        "session producing output with no controller attached",
11635                    );
11636                } else {
11637                    tracing::info!(
11638                        node_id = %self.node_id,
11639                        workspace_id = %address.workspace_id,
11640                        session = ?address.session,
11641                        terminal_size = ?frame.size,
11642                        "session produced its first output",
11643                    );
11644                }
11645            }
11646            watermarks.insert(
11647                address.session.instance_id,
11648                (address.clone(), frame.sequence),
11649            );
11650            if let Some((at, reported)) = self
11651                .pty_input_marks
11652                .lock()
11653                .unwrap_or_else(|poisoned| poisoned.into_inner())
11654                .get_mut(&address.session.instance_id)
11655            {
11656                // First frame after an input: PTY write -> output published.
11657                if !*reported && at.elapsed() < Duration::from_secs(5) {
11658                    *reported = true;
11659                    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");
11660                }
11661            }
11662            events.push(NodeEvent::TerminalFrame { address, frame });
11663        }
11664        drop(watermarks);
11665        if events.is_empty() {
11666            return;
11667        }
11668        let mut history = self
11669            .history
11670            .lock()
11671            .unwrap_or_else(|poisoned| poisoned.into_inner());
11672        let mut envelopes = Vec::with_capacity(events.len());
11673        for event in events {
11674            let sequence = history
11675                .last_sequence
11676                .checked_add(1)
11677                .expect("node event sequence exhausted");
11678            history.last_sequence = sequence;
11679            envelopes.push(NodeEventEnvelope { sequence, event });
11680        }
11681        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11682        drop(history);
11683    }
11684
11685    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11686    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11687    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11688    /// counter every other event kind shares, never a counter of its own --
11689    /// `queue_connection_event`/`queue_connection_event_batch` compare
11690    /// `envelope.sequence` against one `discard_events_through` watermark
11691    /// regardless of which broadcast channel it arrived on, so a
11692    /// per-channel counter would desynchronize that comparison.
11693    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11694        let mut history = self
11695            .history
11696            .lock()
11697            .unwrap_or_else(|poisoned| poisoned.into_inner());
11698        let sequence = history
11699            .last_sequence
11700            .checked_add(1)
11701            .expect("node event sequence exhausted");
11702        history.last_sequence = sequence;
11703        drop(history);
11704        // Debug, not info/warn -- this fires on every single chunk, but it
11705        // is exactly the counter the next drop-measurement pass needs: what
11706        // the node actually emitted (`source_sequence`, `kind`) versus what
11707        // a harness subscriber's own forwarder later logs having received
11708        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11709        // event"), so a gap between the two logs names itself instead of
11710        // needing a new counter added ad hoc.
11711        tracing::debug!(
11712            source_sequence = chunk.source_sequence,
11713            kind = agent_stream_chunk_kind_label(&chunk.kind),
11714            envelope_sequence = sequence,
11715            "publishing agent stream chunk",
11716        );
11717        let envelope = NodeEventEnvelope {
11718            sequence,
11719            event: NodeEvent::AgentStream { address, chunk },
11720        };
11721        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11722    }
11723
11724    fn current_sequence(&self) -> u64 {
11725        self.history
11726            .lock()
11727            .unwrap_or_else(|poisoned| poisoned.into_inner())
11728            .last_sequence
11729    }
11730
11731    fn controller_state(&self) -> Option<ControllerState> {
11732        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11733        let lease = controller.as_ref().copied()?;
11734        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11735        if remaining.is_zero() {
11736            *controller = None;
11737            return None;
11738        }
11739        Some(ControllerState {
11740            connection_id: lease.connection_id,
11741            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11742        })
11743    }
11744
11745    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11746        if role != ClientRole::Operator {
11747            tracing::warn!(
11748                node_id = %self.node_id,
11749                connection_id,
11750                "controller lease rejected: observer connections cannot acquire control",
11751            );
11752            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11753        }
11754        let lease_ms = if lease_ms == 0 {
11755            DEFAULT_CONTROLLER_LEASE_MS
11756        } else {
11757            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11758        };
11759        let now = Instant::now();
11760        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11761        if let Some(active) = controller.as_ref() {
11762            if active.connection_id != connection_id && active.expires_at > now {
11763                tracing::warn!(
11764                    node_id = %self.node_id,
11765                    connection_id,
11766                    holder_connection_id = active.connection_id,
11767                    "controller lease rejected: another operator holds it",
11768                );
11769                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11770            }
11771        }
11772        // A reconnect (this connection already holds the lease, renewing
11773        // before expiry) happens on a periodic cadence; logging it at INFO
11774        // every renewal would be exactly the heartbeat chatter this crate
11775        // family's level discipline forbids. A genuine attach -- the
11776        // controller identity actually changing -- is a state change and
11777        // gets INFO; a same-connection renewal gets DEBUG.
11778        let is_new_attach = match controller.as_ref() {
11779            Some(active) => active.connection_id != connection_id,
11780            None => true,
11781        };
11782        *controller = Some(ControllerLease {
11783            connection_id,
11784            expires_at: now + Duration::from_millis(lease_ms),
11785        });
11786        drop(controller);
11787        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11788        if is_new_attach {
11789            tracing::info!(
11790                node_id = %self.node_id,
11791                connection_id,
11792                lease_ms,
11793                "controller lease acquired",
11794            );
11795        } else {
11796            tracing::debug!(
11797                node_id = %self.node_id,
11798                connection_id,
11799                lease_ms,
11800                "controller lease renewed",
11801            );
11802        }
11803        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11804        Ok(state)
11805    }
11806
11807    fn release_controller(&self, connection_id: u64) -> bool {
11808        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11809        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11810        if released {
11811            *controller = None;
11812        }
11813        drop(controller);
11814        if released {
11815            tracing::info!(
11816                node_id = %self.node_id,
11817                connection_id,
11818                "controller lease released",
11819            );
11820            self.publish(NodeEvent::ControllerChanged { controller: None });
11821        }
11822        released
11823    }
11824
11825    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11826    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11827    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11828    /// provider session; it only cancels the orphan timer. Disconnect/reload
11829    /// draft kills are owned by HQ and arm only on a real client WS close.
11830    /// ACP path of launch options: once the agent advertises its config options, write the
11831    /// pending model / effort through session/set_config_option (session/set_model when the
11832    /// agent has no model option). Gaps are logged, never forced.
11833    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11834        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11835            return;
11836        };
11837        let id = address.session.instance_id;
11838        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11839            return;
11840        };
11841        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11842            .iter()
11843            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11844            .collect();
11845        let (writes, set_model, effort_gap) =
11846            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11847        if set_model {
11848            if let Some(model_id) = model.clone() {
11849                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11850                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11851                }
11852            }
11853        }
11854        for (option_id, value_json) in writes {
11855            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11856                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11857            }
11858        }
11859        if effort_gap {
11860            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11861        }
11862        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11863    }
11864
11865    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11866    /// answer every session/request_permission with approve.
11867    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11868        let id = address.session.instance_id;
11869        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11870        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11871            crate::auto_permit::forget_acp(id.0);
11872            return;
11873        }
11874        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11875        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11876            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11877        }
11878        match provider_event {
11879            ProviderEvent::ModeChanged { mode_id, available } => {
11880                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11881                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11882                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11883                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11884                    }
11885                }
11886            }
11887            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11888                match self.dispatch(ControlCommand::ResolveInteraction {
11889                    instance_id: id,
11890                    generation: address.session.generation,
11891                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11892                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11893                }) {
11894                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11895                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11896                }
11897            }
11898            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11899            // must not drop the auto-permit registration; that happens on process exit below.
11900            _ => {}
11901        }
11902    }
11903
11904    fn mark_spawn_provisional(&self, address: SessionAddress) {
11905        let id = address.session.instance_id;
11906        self.provisional_spawns
11907            .lock()
11908            .unwrap_or_else(|poisoned| poisoned.into_inner())
11909            .insert(id, (address, Instant::now()));
11910        tracing::info!(
11911            node_id = %self.node_id,
11912            instance_id = id.0,
11913            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11914            "draft spawn; waiting for HQ view",
11915        );
11916    }
11917
11918    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11919        let removed = self
11920            .provisional_spawns
11921            .lock()
11922            .unwrap_or_else(|poisoned| poisoned.into_inner())
11923            .remove(&instance_id);
11924        if removed.is_some() {
11925            tracing::info!(
11926                node_id = %self.node_id,
11927                instance_id = instance_id.0,
11928                "draft spawn claimed by HQ view",
11929            );
11930        }
11931    }
11932
11933    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11934        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11935        let mut map = self
11936            .provisional_spawns
11937            .lock()
11938            .unwrap_or_else(|poisoned| poisoned.into_inner());
11939        let due: Vec<AgentInstanceId> = map
11940            .iter()
11941            .filter(|(_, (_, at))| at.elapsed() >= grace)
11942            .map(|(id, _)| *id)
11943            .collect();
11944        due.into_iter()
11945            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11946            .collect()
11947    }
11948
11949    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11950        let instance_id = address.session.instance_id;
11951        tracing::warn!(
11952            node_id = %self.node_id,
11953            instance_id = instance_id.0,
11954            workspace = %address.workspace_id,
11955            reason = "attach_never_arrived",
11956            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11957            "draft spawn never viewed; stopping PTY",
11958        );
11959        let _ = self.dispatch(ControlCommand::Stop {
11960            instance_id,
11961            force: true,
11962        });
11963        let _ = self.remove_session(&address).await;
11964    }
11965
11966    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11967        if role != ClientRole::Operator {
11968            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11969        }
11970        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11971        let now = Instant::now();
11972        match controller.as_ref().copied() {
11973            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11974            Some(active) if active.expires_at <= now => {
11975                *controller = None;
11976                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11977            }
11978            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11979        }
11980    }
11981
11982    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11983        if !self
11984            .workspaces
11985            .read()
11986            .unwrap_or_else(|poisoned| poisoned.into_inner())
11987            .contains_key(&address.workspace_id)
11988        {
11989            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11990        }
11991        let binding = self
11992            .session_bindings
11993            .lock()
11994            .unwrap_or_else(|poisoned| poisoned.into_inner())
11995            .get(&address.session.instance_id)
11996            .cloned()
11997            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11998        if binding.workspace_id != address.workspace_id {
11999            return Err(failure(
12000                NodeFailureCode::SessionWorkspaceMismatch,
12001                "session belongs to another workspace",
12002            ));
12003        }
12004        if binding.generation != address.session.generation {
12005            return Err(failure(
12006                NodeFailureCode::StaleGeneration,
12007                &format!(
12008                    "stale session generation: expected {}, received {}",
12009                    binding.generation.0,
12010                    address.session.generation.0,
12011                ),
12012            ));
12013        }
12014        let snapshot = self.handle.snapshot();
12015        let Some(current) = snapshot
12016            .sessions
12017            .iter()
12018            .find(|item| item.instance_id == address.session.instance_id)
12019        else {
12020            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12021        };
12022        if current.generation != address.session.generation {
12023            return Err(failure(
12024                NodeFailureCode::StaleGeneration,
12025                &format!(
12026                    "stale session generation: expected {}, received {}",
12027                    current.generation.0,
12028                    address.session.generation.0,
12029                ),
12030            ));
12031        }
12032        Ok(current.agent_id.clone())
12033    }
12034
12035    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12036        self.dispatch_envelope(self.prepare_command(command))
12037    }
12038
12039    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12040        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12041        CommandEnvelope {
12042            id: CommandId(id),
12043            command,
12044        }
12045    }
12046
12047    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12048        let command_id = envelope.id;
12049        self.handle
12050            .dispatch(envelope)
12051            .map_err(|error| match error {
12052                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12053                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12054            })?;
12055        Ok(command_id)
12056    }
12057
12058    /// Records the kernel's own reason for asynchronously refusing
12059    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12060    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12061    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12062    /// first, and if the map is still at capacity after that, the single
12063    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12064    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12065    /// is always the oldest.
12066    fn record_rejected_command(
12067        &self,
12068        command_id: CommandId,
12069        message: String,
12070        unsupported_transport: bool,
12071    ) {
12072        let now = Instant::now();
12073        let mut reasons = self.rejected_command_reasons.lock()
12074            .unwrap_or_else(|poisoned| poisoned.into_inner());
12075        reasons.retain(|_, reason| reason.expires_at > now);
12076        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12077            if let Some(&oldest) = reasons.keys().next() {
12078                reasons.remove(&oldest);
12079            }
12080        }
12081        reasons.insert(
12082            command_id,
12083            RejectedCommandReason {
12084                message,
12085                unsupported_transport,
12086                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12087            },
12088        );
12089    }
12090
12091    /// Removes and returns the kernel's reason for refusing `command_id`,
12092    /// if the drive loop has recorded one and it has not expired -- see
12093    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12094    /// wait loop calls this instead of blindly polling out its whole
12095    /// deadline when the command it is waiting on turns out to have been
12096    /// refused rather than merely slow.
12097    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12098        let now = Instant::now();
12099        let mut reasons = self.rejected_command_reasons.lock()
12100            .unwrap_or_else(|poisoned| poisoned.into_inner());
12101        match reasons.remove(&command_id) {
12102            Some(reason) if reason.expires_at > now => Some(reason),
12103            _ => None,
12104        }
12105    }
12106
12107    async fn dispatch_bounded(
12108        &self,
12109        command: ControlCommand,
12110        timeout_duration: Duration,
12111    ) -> Result<CommandId, NodeFailure> {
12112        let deadline = Instant::now() + timeout_duration;
12113        loop {
12114            match self.dispatch(command.clone()) {
12115                Ok(command_id) => return Ok(command_id),
12116                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12117                    sleep(Duration::from_millis(2)).await;
12118                }
12119                Err(error) => return Err(error),
12120            }
12121        }
12122    }
12123
12124    fn internal_session_snapshot(
12125        &self,
12126        address: &SessionAddress,
12127    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12128        self.validate_address(address)?;
12129        self.handle
12130            .snapshot()
12131            .sessions
12132            .clone()
12133            .into_iter()
12134            .find(|session| {
12135                session.instance_id == address.session.instance_id
12136                    && session.generation == address.session.generation
12137            })
12138            .ok_or_else(|| {
12139                failure(
12140                    NodeFailureCode::UnknownSession,
12141                    "session snapshot is unavailable",
12142                )
12143            })
12144    }
12145
12146    /// Confirms `address` names a live session spawned over the ACP
12147    /// transport and returns its current snapshot -- the gate every
12148    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12149    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12150    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12151    /// the session's own recorded `TransportKind` does, and that lives on
12152    /// the live snapshot, not the binding.
12153    fn require_acp_session(
12154        &self,
12155        address: &SessionAddress,
12156    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12157        let snapshot = self.internal_session_snapshot(address)?;
12158        if snapshot.transport != TransportKind::Acp {
12159            return Err(failure(
12160                NodeFailureCode::UnsupportedTransport,
12161                "this verb requires a session spawned over the ACP transport",
12162            ));
12163        }
12164        Ok(snapshot)
12165    }
12166
12167    async fn dispatch_history_bounded(
12168        &self,
12169        address: &SessionAddress,
12170        command: ControlCommand,
12171        operation: HistoryOperation,
12172    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12173        self.validate_address(address)?;
12174        let started_after = self.current_sequence();
12175        let command_id = self
12176            .dispatch_bounded(
12177                command,
12178                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12179            )
12180            .await?;
12181        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12182        let mut scan_after = started_after;
12183        let mut accepted_after = None;
12184        loop {
12185            let events = self
12186                .history
12187                .lock()
12188                .unwrap_or_else(|poisoned| poisoned.into_inner())
12189                .events
12190                .iter()
12191                .filter(|envelope| envelope.sequence > scan_after)
12192                .cloned()
12193                .collect::<Vec<_>>();
12194            for envelope in events {
12195                scan_after = scan_after.max(envelope.sequence);
12196                let NodeEvent::Control {
12197                    address: event_address,
12198                    event,
12199                } = envelope.event
12200                else {
12201                    continue;
12202                };
12203                if event_address != *address {
12204                    continue;
12205                }
12206                if event.command_id == Some(command_id) {
12207                    match &event.event {
12208                        ControlEventKind::CommandRejected { .. } => {
12209                            return Err(failure(
12210                                NodeFailureCode::InvalidRequest,
12211                                "history request was rejected",
12212                            ));
12213                        }
12214                        ControlEventKind::HistoryRequested {
12215                            operation: requested,
12216                            ..
12217                        } if requested == &operation => {
12218                            accepted_after = Some(envelope.sequence);
12219                            continue;
12220                        }
12221                        _ => {}
12222                    }
12223                }
12224                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12225                    match (&operation, &event.event) {
12226                        (
12227                            HistoryOperation::Discover { .. },
12228                            ControlEventKind::HistoryDiscovered { .. },
12229                        )
12230                        | (
12231                            HistoryOperation::Load { .. },
12232                            ControlEventKind::HistoryLoaded { .. },
12233                        ) => return self.internal_session_snapshot(address),
12234                        (_, ControlEventKind::HistoryFailed { .. }) => {
12235                            return Err(failure(
12236                                NodeFailureCode::BackendOperationFailed,
12237                                "history operation failed",
12238                            ));
12239                        }
12240                        _ => {}
12241                    }
12242                }
12243            }
12244            if Instant::now() >= deadline {
12245                return Err(failure(
12246                    NodeFailureCode::BackendBusy,
12247                    if accepted_after.is_some() {
12248                        "history operation did not settle before the bounded deadline"
12249                    } else {
12250                        "history operation was not accepted before the bounded deadline"
12251                    },
12252                ));
12253            }
12254            sleep(Duration::from_millis(2)).await;
12255        }
12256    }
12257
12258    async fn discover_history(
12259        &self,
12260        address: &SessionAddress,
12261        limit: u16,
12262    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12263        let query = HistoryQuery {
12264            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12265            limit,
12266        };
12267        query
12268            .validate()
12269            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12270        let snapshot = self
12271            .dispatch_history_bounded(
12272                address,
12273                ControlCommand::DiscoverHistory {
12274                    instance_id: address.session.instance_id,
12275                    query: query.clone(),
12276                },
12277                HistoryOperation::Discover { query },
12278            )
12279            .await?;
12280        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12281            return Err(failure(
12282                NodeFailureCode::BackendOperationFailed,
12283                "history discovery settled into an invalid state",
12284            ));
12285        }
12286        Ok(snapshot.history.candidates)
12287    }
12288
12289    async fn load_history(
12290        &self,
12291        address: &SessionAddress,
12292        candidate_id: String,
12293    ) -> Result<HistorySessionRecord, NodeFailure> {
12294        validate_candidate_id(&candidate_id)
12295            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12296        let snapshot = self
12297            .dispatch_history_bounded(
12298                address,
12299                ControlCommand::LoadHistory {
12300                    instance_id: address.session.instance_id,
12301                    candidate_id: candidate_id.clone(),
12302                },
12303                HistoryOperation::Load {
12304                    candidate_id: candidate_id.clone(),
12305                },
12306            )
12307            .await?;
12308        if snapshot.history.pending.is_some()
12309            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12310        {
12311            return Err(failure(
12312                NodeFailureCode::BackendOperationFailed,
12313                "history load settled into an invalid state",
12314            ));
12315        }
12316        snapshot.history.loaded.ok_or_else(|| {
12317            failure(
12318                NodeFailureCode::BackendOperationFailed,
12319                "history load produced no bounded session",
12320            )
12321        })
12322    }
12323
12324    /// `allow_clean_detachment`: `false` for the on-demand
12325    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12326    /// managed record must stay untouched, still live, for the whole call);
12327    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12328    /// reacts to the very clean-exit event that detaches the record out from
12329    /// under this same call — see `session_record_export_target_matches`.
12330    async fn export_context_pack_for_session_record(
12331        &self,
12332        record_id: &SessionRecordId,
12333        session: &SessionAddress,
12334        allow_clean_detachment: bool,
12335    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12336        // C2 replaces a relayed failure's prose with a canned per-code
12337        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12338        // distinct refusals are indistinguishable to anyone downstream --
12339        // and a refusal here expires a continuation, which keeps no field
12340        // for why. The prose stays on the node, where it was written.
12341        let outcome = self
12342            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12343            .await;
12344        if let Err(failure) = &outcome {
12345            tracing::warn!(
12346                record = %record_id,
12347                code = ?failure.code,
12348                reason = %failure.message,
12349                "session-record context-pack export refused",
12350            );
12351        }
12352        outcome
12353    }
12354
12355    async fn export_context_pack_for_session_record_inner(
12356        &self,
12357        record_id: &SessionRecordId,
12358        session: &SessionAddress,
12359        allow_clean_detachment: bool,
12360    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12361        // Several steps here refuse with the same BackendOperationFailed
12362        // code and no prose of their own, so which step failed is
12363        // otherwise unrecoverable -- and a refusal here expires a
12364        // continuation.
12365        let step = |name: &'static str| move |failure: NodeFailure| {
12366            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12367            failure
12368        };
12369        let record = self.record(record_id).map_err(step("record-lookup"))?;
12370        // `provider_session` is `Some` only for a provider whose resolved
12371        // runtime policy admits live identity verification (raw PTY plus a
12372        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12373        // The PTY-sidecar-observation shape (no fleet provider today, but
12374        // capability-keyed rather than vendor-locked) hardcodes
12375        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12376        // so its records go straight to `Live` with `provider_session: None`
12377        // and stay that way for the record's entire life -- that is this
12378        // shape's permanent resting state, not a race with observation, and
12379        // `discover_history`/`load_history` below do not need an identity to
12380        // do their job for it. `IdentityPending` is the actual race (an
12381        // identity-admitted provider whose observation has not landed yet):
12382        // it keeps refusing here rather than falling back, because that
12383        // provider's own history the fallback would otherwise trust is not
12384        // guaranteed to be exact without the identity it is still waiting on.
12385        let identity = match record.provider_session.clone() {
12386            Some(identity) => {
12387                if identity.key != ProviderSessionKey::SessionId {
12388                    return Err(step("provider-identity-key")(failure(
12389                        NodeFailureCode::BackendOperationFailed,
12390                        "managed session provider identity cannot bind history exactly",
12391                    )));
12392                }
12393                Some(identity)
12394            }
12395            None if record.state == ManagedSessionState::IdentityPending => {
12396                return Err(step("provider-session-identity")(failure(
12397                    NodeFailureCode::BackendOperationFailed,
12398                    "managed session has not yet observed its provider session identity",
12399                )));
12400            }
12401            None => None,
12402        };
12403        self.revalidate_session_record_context_export(
12404            &record,
12405            identity.as_ref(),
12406            session,
12407            allow_clean_detachment,
12408        ).map_err(step("revalidate-before-discovery"))?;
12409        let candidates = self
12410            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12411            .await
12412            .map_err(step("discover-history"))?;
12413        // With no verified identity to filter by, the discovered set must
12414        // still resolve to exactly one candidate: the same ambiguity refusal
12415        // an identity filter would otherwise produce is the only safety
12416        // property left available for this shape.
12417        let candidate_id = select_history_candidate(
12418            &candidates,
12419            identity.as_ref().map(|identity| identity.id.as_str()),
12420        ).map_err(step("unique-candidate"))?;
12421        let loaded = self.load_history(session, candidate_id.clone()).await
12422            .map_err(step("load-history"))?;
12423        if let Some(identity) = identity.as_ref() {
12424            if loaded.session_id != identity.id {
12425                return Err(failure(
12426                    NodeFailureCode::SessionRecordConflict,
12427                    "loaded history identity does not match the managed session record",
12428                ));
12429            }
12430        }
12431        self.revalidate_session_record_context_export(
12432            &record,
12433            identity.as_ref(),
12434            session,
12435            allow_clean_detachment,
12436        )?;
12437        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12438            .map_err(step("revalidate-loaded-history"))?;
12439        let pack = self.materialize_context_pack(session).await
12440            .map_err(step("materialize-context-pack"))?;
12441        self.commit_context_pack_for_session_record(
12442            &record,
12443            identity.as_ref(),
12444            session,
12445            &candidate_id,
12446            &loaded,
12447            pack,
12448            allow_clean_detachment,
12449        )
12450    }
12451
12452    async fn export_context_pack(
12453        &self,
12454        address: &SessionAddress,
12455    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12456        let pack = self.materialize_context_pack(address).await?;
12457        self.commit_context_pack(pack)
12458    }
12459
12460    async fn materialize_context_pack(
12461        &self,
12462        address: &SessionAddress,
12463    ) -> Result<NodeContextPack, NodeFailure> {
12464        let snapshot = self.internal_session_snapshot(address)?;
12465        if !context_pack_source_status_is_usable(&snapshot.status) {
12466            return Err(failure(
12467                NodeFailureCode::InvalidRequest,
12468                "context export requires a running or successfully exited source session",
12469            ));
12470        }
12471        if snapshot.history.pending.is_some()
12472            || snapshot.history.loaded_candidate_id.is_none()
12473        {
12474            return Err(failure(
12475                NodeFailureCode::InvalidRequest,
12476                "context export requires settled loaded history",
12477            ));
12478        }
12479        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12480            failure(
12481                NodeFailureCode::InvalidRequest,
12482                "context export requires settled loaded history",
12483            )
12484        })?;
12485        let source_provider = snapshot.agent_id.clone();
12486        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12487        let repository = self
12488            .context_pack_repository(&address.workspace_id)
12489            .await?;
12490        let current = self.internal_session_snapshot(address)?;
12491        if !context_pack_source_status_is_usable(&current.status)
12492            || current.agent_id != source_provider
12493            || current.history.pending.is_some()
12494            || current.history.loaded_candidate_id != loaded_candidate_id
12495            || current.history.loaded.as_ref() != Some(&loaded)
12496        {
12497            return Err(failure(
12498                NodeFailureCode::ContextPackMaterializationFailed,
12499                "context source changed during bounded repository capture",
12500            ));
12501        }
12502        NodeContextPack::export_with_repository(
12503            ContextPackLineageReceipt {
12504                source_node_id: self.node_id.clone(),
12505                source_session: address.clone(),
12506                source_provider,
12507            },
12508            &loaded,
12509            Some(repository),
12510        )
12511        .map_err(|_| {
12512            failure(
12513                NodeFailureCode::ContextPackMaterializationFailed,
12514                "bounded context pack export failed",
12515            )
12516        })
12517    }
12518
12519    /// Write-through to the durable [`ContextPackStore`], if one is
12520    /// configured (`state_path` is `Some`). A node with no durable state
12521    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12522    /// open at all and this is a no-op, matching `persist_state`'s own
12523    /// "durability optional, degrade gracefully when unconfigured" idiom.
12524    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12525        let mut store = self
12526            .context_pack_store
12527            .lock()
12528            .unwrap_or_else(|poisoned| poisoned.into_inner());
12529        let Some(store) = store.as_mut() else {
12530            return Ok(());
12531        };
12532        store.commit(pack).map_err(|_| {
12533            failure(
12534                NodeFailureCode::ContextPackMaterializationFailed,
12535                "durable context pack commit failed",
12536            )
12537        })
12538    }
12539
12540    fn commit_context_pack(
12541        &self,
12542        pack: NodeContextPack,
12543    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12544        self.commit_context_pack_durably(&pack)?;
12545        self.context_catalog
12546            .write()
12547            .unwrap_or_else(|poisoned| poisoned.into_inner())
12548            .insert(pack)
12549            .map_err(|_| {
12550                failure(
12551                    NodeFailureCode::ContextPackMaterializationFailed,
12552                    "context pack catalog is full",
12553                )
12554            })
12555    }
12556
12557    fn commit_context_pack_for_session_record(
12558        &self,
12559        expected: &ManagedSessionRecord,
12560        identity: Option<&ProviderSessionIdentity>,
12561        session: &SessionAddress,
12562        candidate_id: &str,
12563        loaded: &HistorySessionRecord,
12564        pack: NodeContextPack,
12565        allow_clean_detachment: bool,
12566    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12567        let current_root = self.workspace_root(&expected.workspace_id)?;
12568        let _transaction = self
12569            .state_transaction
12570            .lock()
12571            .unwrap_or_else(|poisoned| poisoned.into_inner());
12572        let records = self
12573            .session_records
12574            .lock()
12575            .unwrap_or_else(|poisoned| poisoned.into_inner());
12576        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12577            failure(
12578                NodeFailureCode::SessionRecordConflict,
12579                "managed session record disappeared during context export",
12580            )
12581        })?;
12582        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12583            return Err(failure(
12584                NodeFailureCode::SessionRecordConflict,
12585                "managed session record changed during context export",
12586            ));
12587        }
12588        let bindings = self
12589            .session_bindings
12590            .lock()
12591            .unwrap_or_else(|poisoned| poisoned.into_inner());
12592        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12593            failure(
12594                NodeFailureCode::SessionRecordConflict,
12595                "managed session runtime binding disappeared during context export",
12596            )
12597        })?;
12598        let runtime = self
12599            .handle
12600            .snapshot()
12601            .sessions
12602            .iter()
12603            .find(|snapshot| {
12604                snapshot.instance_id == session.session.instance_id
12605                    && snapshot.generation == session.session.generation
12606            })
12607            .cloned()
12608            .ok_or_else(|| {
12609                failure(
12610                    NodeFailureCode::SessionRecordConflict,
12611                    "managed session runtime disappeared during context export",
12612                )
12613            })?;
12614        let receipt = pack.receipt();
12615        if !session_record_context_export_binding_is_exact(
12616            expected,
12617            current,
12618            identity,
12619            session,
12620            binding,
12621            &runtime.agent_id,
12622            &current_root,
12623            allow_clean_detachment,
12624        ) || !session_record_context_export_source_is_usable(
12625            current,
12626            &runtime.status,
12627            allow_clean_detachment,
12628        ) || runtime.history.pending.is_some()
12629            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12630            || runtime.history.loaded.as_ref() != Some(loaded)
12631            || receipt.lineage.source_node_id != self.node_id
12632            || receipt.lineage.source_session != *session
12633            || receipt.lineage.source_provider != current.provider
12634            || receipt.source_message_count != loaded.message_count
12635        {
12636            return Err(failure(
12637                NodeFailureCode::SessionRecordConflict,
12638                "managed session source changed before context catalog commit",
12639            ));
12640        }
12641        self.commit_context_pack_durably(&pack)?;
12642        self.context_catalog
12643            .write()
12644            .unwrap_or_else(|poisoned| poisoned.into_inner())
12645            .insert(pack)
12646            .map_err(|_| {
12647                failure(
12648                    NodeFailureCode::ContextPackMaterializationFailed,
12649                    "context pack catalog is full",
12650                )
12651            })
12652    }
12653
12654    async fn context_pack_repository(
12655        &self,
12656        workspace_id: &WorkspaceId,
12657    ) -> Result<ContextPackRepository, NodeFailure> {
12658        let first = self
12659            .context_pack_repository_observation(workspace_id)
12660            .await?;
12661        let second = self
12662            .context_pack_repository_observation(workspace_id)
12663            .await?;
12664        stable_context_pack_repository(first, second)
12665    }
12666
12667    async fn context_pack_repository_observation(
12668        &self,
12669        workspace_id: &WorkspaceId,
12670    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12671        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12672        let head = if git.is_repository {
12673            let root = self.workspace_root(workspace_id)?;
12674            match resolve_base_commit_with_timeout(
12675                &root,
12676                "HEAD",
12677                GIT_COMMAND_TIMEOUT_MS,
12678            )
12679            .await
12680            {
12681                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12682                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12683                    ContextPackRepositoryHead::Unborn
12684                }
12685                Err(_) => {
12686                    return Err(failure(
12687                        NodeFailureCode::ContextPackMaterializationFailed,
12688                        "context pack HEAD observation failed",
12689                    ));
12690                }
12691            }
12692        } else {
12693            ContextPackRepositoryHead::NotRepository
12694        };
12695        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12696        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12697            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12698                failure(
12699                    NodeFailureCode::InvalidRepositoryPath,
12700                    "context pack selected file path is invalid",
12701                )
12702            })?;
12703            match self.read_workspace_file(workspace_id.clone(), path).await {
12704                Ok(WorkspaceFileRead {
12705                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12706                    ..
12707                }) => files.push(ContextPackRepositoryFileSource::utf8(
12708                    selected_path,
12709                    text,
12710                    byte_len,
12711                )),
12712                Ok(WorkspaceFileRead {
12713                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12714                    ..
12715                }) => files.push(ContextPackRepositoryFileSource::skipped(
12716                    selected_path,
12717                    ContextPackSelectedFileSkipReason::NonUtf8,
12718                    Some(byte_len),
12719                )),
12720                Ok(WorkspaceFileRead {
12721                    content: WorkspaceFileContent::TooLarge { .. },
12722                    ..
12723                }) => files.push(ContextPackRepositoryFileSource::skipped(
12724                    selected_path,
12725                    ContextPackSelectedFileSkipReason::TooLarge,
12726                    None,
12727                )),
12728                Err(error) => {
12729                    let reason = if matches!(
12730                        error.code,
12731                        NodeFailureCode::InvalidRepositoryPath
12732                            | NodeFailureCode::RepositoryFileNotRegular
12733                            | NodeFailureCode::RepositoryPathUnsafe
12734                    ) {
12735                        ContextPackSelectedFileSkipReason::Unsafe
12736                    } else {
12737                        ContextPackSelectedFileSkipReason::Unavailable
12738                    };
12739                    files.push(ContextPackRepositoryFileSource::skipped(
12740                        selected_path,
12741                        reason,
12742                        None,
12743                    ));
12744                }
12745            }
12746        }
12747        Ok(ContextPackRepositoryObservation { head, git, files })
12748    }
12749
12750    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12751        let referenced_by_binding = self
12752            .session_bindings
12753            .lock()
12754            .unwrap_or_else(|poisoned| poisoned.into_inner())
12755            .values()
12756            .any(|binding| {
12757                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12758            });
12759        let referenced_by_record = self
12760            .session_records
12761            .lock()
12762            .unwrap_or_else(|poisoned| poisoned.into_inner())
12763            .records
12764            .values()
12765            .any(|record| record.context_id.as_ref() == Some(context_id));
12766        let referenced_by_materialization = self
12767            .materializations
12768            .lock()
12769            .unwrap_or_else(|poisoned| poisoned.into_inner())
12770            .values()
12771            .any(|ownership| {
12772                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12773            });
12774        if referenced_by_binding
12775            || referenced_by_record
12776            || referenced_by_materialization
12777        {
12778            return Err(failure(
12779                NodeFailureCode::ContextPackBusy,
12780                "context pack is still referenced",
12781            ));
12782        }
12783        let mut catalog = self.context_catalog
12784            .write()
12785            .unwrap_or_else(|poisoned| poisoned.into_inner());
12786        if catalog.get(context_id).is_none() {
12787            return Err(failure(
12788                NodeFailureCode::UnknownContextPack,
12789                "context pack is unavailable",
12790            ));
12791        }
12792        self.spawn_idempotency
12793            .lock()
12794            .unwrap_or_else(|poisoned| poisoned.into_inner())
12795            .entries
12796            .retain(|_, entry| !entry.value.references_context(context_id));
12797        let removed = catalog.remove(context_id);
12798        debug_assert!(removed.is_some(), "validated context pack remains present");
12799        Ok(())
12800    }
12801
12802    async fn dispatch_input_bounded(
12803        &self,
12804        address: &SessionAddress,
12805        action: InputAction,
12806    ) -> Result<(), NodeFailure> {
12807        let started_after = self.current_sequence();
12808        self.pty_input_marks
12809            .lock()
12810            .unwrap_or_else(|poisoned| poisoned.into_inner())
12811            .insert(address.session.instance_id, (Instant::now(), false));
12812        self.pty_input_wake.notify_one();
12813        let command_id = self
12814            .dispatch_bounded(
12815                ControlCommand::SendInput {
12816                    instance_id: address.session.instance_id,
12817                    action,
12818                },
12819                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12820            )
12821            .await?;
12822        // A semantic prompt may spend the provider's full readiness budget before
12823        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12824        // but strictly beyond every readiness policy in this node's catalog.
12825        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12826        let mut scan_after = started_after;
12827        let mut accepted_after = None;
12828        loop {
12829            let events = self
12830                .history
12831                .lock()
12832                .unwrap_or_else(|poisoned| poisoned.into_inner())
12833                .events
12834                .iter()
12835                .filter(|envelope| envelope.sequence > scan_after)
12836                .cloned()
12837                .collect::<Vec<_>>();
12838            for envelope in events {
12839                scan_after = scan_after.max(envelope.sequence);
12840                let NodeEvent::Control {
12841                    address: event_address,
12842                    event,
12843                } = envelope.event
12844                else {
12845                    continue;
12846                };
12847                if event_address != *address {
12848                    continue;
12849                }
12850                if event.command_id == Some(command_id) {
12851                    match &event.event {
12852                        ControlEventKind::CommandRejected { message } => {
12853                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12854                        }
12855                        ControlEventKind::InputRequested { .. } => {
12856                            accepted_after = Some(envelope.sequence);
12857                            continue;
12858                        }
12859                        _ => {}
12860                    }
12861                }
12862                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12863                    match &event.event {
12864                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12865                        ControlEventKind::InputFailed { message, .. } => {
12866                            return Err(failure(
12867                                NodeFailureCode::BackendOperationFailed,
12868                                message,
12869                            ));
12870                        }
12871                        _ => {}
12872                    }
12873                }
12874            }
12875            if Instant::now() >= deadline {
12876                return Err(failure(
12877                    NodeFailureCode::BackendBusy,
12878                    if accepted_after.is_some() {
12879                        "input was accepted but did not settle before the bounded deadline"
12880                    } else {
12881                        "input was not accepted before the bounded deadline"
12882                    },
12883                ));
12884            }
12885            sleep(Duration::from_millis(2)).await;
12886        }
12887    }
12888
12889    async fn dispatch_resize_bounded(
12890        &self,
12891        address: &SessionAddress,
12892        size: gate4agent_types::TerminalSize,
12893    ) -> Result<(), NodeFailure> {
12894        let started_after = self.current_sequence();
12895        let command_id = self
12896            .dispatch_bounded(
12897                ControlCommand::Resize {
12898                    instance_id: address.session.instance_id,
12899                    size,
12900                },
12901                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12902            )
12903            .await?;
12904        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12905        let mut scan_after = started_after;
12906        let mut accepted_after = None;
12907        loop {
12908            let events = self
12909                .history
12910                .lock()
12911                .unwrap_or_else(|poisoned| poisoned.into_inner())
12912                .events
12913                .iter()
12914                .filter(|envelope| envelope.sequence > scan_after)
12915                .cloned()
12916                .collect::<Vec<_>>();
12917            for envelope in events {
12918                scan_after = scan_after.max(envelope.sequence);
12919                let NodeEvent::Control {
12920                    address: event_address,
12921                    event,
12922                } = envelope.event
12923                else {
12924                    continue;
12925                };
12926                if event_address != *address {
12927                    continue;
12928                }
12929                if event.command_id == Some(command_id) {
12930                    match &event.event {
12931                        ControlEventKind::CommandRejected { message } => {
12932                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12933                        }
12934                        ControlEventKind::ResizeRequested {
12935                            size: requested_size,
12936                            ..
12937                        } if *requested_size == size => {
12938                            accepted_after = Some(envelope.sequence);
12939                            continue;
12940                        }
12941                        _ => {}
12942                    }
12943                }
12944                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12945                    match &event.event {
12946                        ControlEventKind::Resized { size: settled_size }
12947                            if *settled_size == size =>
12948                        {
12949                            return Ok(());
12950                        }
12951                        ControlEventKind::ResizeFailed { message } => {
12952                            return Err(failure(
12953                                NodeFailureCode::BackendOperationFailed,
12954                                message,
12955                            ));
12956                        }
12957                        _ => {}
12958                    }
12959                }
12960            }
12961            if Instant::now() >= deadline {
12962                return Err(failure(
12963                    NodeFailureCode::BackendBusy,
12964                    if accepted_after.is_some() {
12965                        "resize was accepted but did not settle before the bounded deadline"
12966                    } else {
12967                        "resize was not accepted before the bounded deadline"
12968                    },
12969                ));
12970            }
12971            sleep(Duration::from_millis(2)).await;
12972        }
12973    }
12974
12975    async fn spawn_session(
12976        &self,
12977        workspace_id: WorkspaceId,
12978        provider: AgentId,
12979        mode: SessionMode,
12980        terminal_size: gate4agent_types::TerminalSize,
12981        initial_prompt: Option<String>,
12982    ) -> Result<SessionAddress, NodeFailure> {
12983        if self.managed_worktrees.lock()
12984            .unwrap_or_else(|poisoned| poisoned.into_inner())
12985            .lease_for_workspace(&workspace_id)
12986            .is_some()
12987        {
12988            return Err(failure(
12989                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12990                "managed worktree targets may only be spawned through their lease request",
12991            ));
12992        }
12993        self
12994            .spawn_session_with_deadline(
12995                workspace_id,
12996                provider.clone(),
12997                mode,
12998                terminal_size,
12999                initial_prompt,
13000                None,
13001                None,
13002                None,
13003                None,
13004                None,
13005                SpawnRecordPolicy::ProviderIdentityOnly,
13006                None,
13007                &[],
13008                None,
13009                // `NodeRequest::Spawn` (this method's only caller) carries no
13010                // approval-level selection of its own -- see
13011                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13012                // the one operator-wire path that can choose something other
13013                // than the axis default. Auto-permit may lower it where the agent
13014                // cannot offer its full-auto mode (claude ACP under root).
13015                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13016                None,
13017                None,
13018            )
13019            .await
13020            .map(|(session, _runtime_policy)| session)
13021    }
13022
13023    async fn spawn_session_with_deadline(
13024        &self,
13025        workspace_id: WorkspaceId,
13026        provider: AgentId,
13027        mode: SessionMode,
13028        terminal_size: gate4agent_types::TerminalSize,
13029        initial_prompt: Option<String>,
13030        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13031        bundle: Option<ResolvedBundleReceipt>,
13032        context: Option<ResolvedContextPackReceipt>,
13033        managed_authority: Option<ManagedWorktreeLeaseId>,
13034        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13035        record_policy: SpawnRecordPolicy,
13036        deadline: Option<Instant>,
13037        required_capabilities: &[ProviderRuntimeCapability],
13038        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13039        approval_level: ApprovalLevel,
13040        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13041        // Opaque dig2browser station profile id (ids only). Feature
13042        // `dig2-station-probe`: after probe at resolve, spawn takes an
13043        // exclusive node-local lease; cleared on session end. Never
13044        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13045        // already happened on the receipt path).
13046        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13047    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13048        let reserved_lease = self.managed_worktrees.lock()
13049            .unwrap_or_else(|poisoned| poisoned.into_inner())
13050            .lease_for_workspace(&workspace_id);
13051        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13052            (Some(expected), Some(provided)) if expected == provided => {}
13053            (None, None) => {}
13054            _ => {
13055                return Err(failure(
13056                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13057                    "spawn lacks exact managed worktree lease authority",
13058                ));
13059            }
13060        }
13061        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13062        // bindings) on the same workspace cwd. Parallel path: managed worktree
13063        // spawn so each profile gets its own workspace_id.
13064        self.ensure_workspace_environment_profile_compatible(
13065            &workspace_id,
13066            environment_profile.as_ref(),
13067        )?;
13068        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13069            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13070            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13071            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13072            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13073        };
13074        // This session's own transport -- computed once, here, from `mode`,
13075        // and reused both to admit spawn (below) and, further down, to
13076        // compute the final `runtime_policy` this session actually operates
13077        // under (`provider_runtime::policy_for_transport`). Registration
13078        // reuses this exact value rather than recomputing it a second time.
13079        let transport = match mode {
13080            SessionMode::Pty => TransportKind::Pty,
13081            SessionMode::Inline => TransportKind::Pipe,
13082            SessionMode::Acp => TransportKind::Acp,
13083        };
13084        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13085        let acp_transport = self.provider_supports_acp_transport(&provider);
13086        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13087            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13088                NodeFailureCode::UnsupportedSpawnCapability,
13089                "pre-admitted provider runtime no longer satisfies spawn requirements",
13090            ))?;
13091            runtime_policy
13092        } else if let Some(deadline) = deadline {
13093            let remaining = spawn_deadline_remaining(deadline)?;
13094            timeout(
13095                remaining,
13096                self.admit_provider_runtime(&provider, runtime_requirement),
13097            )
13098            .await
13099            .map_err(|_| {
13100                failure(
13101                    NodeFailureCode::SpawnDeadlineExceeded,
13102                    "provider admission exceeded the spawn deadline",
13103                )
13104            })??
13105        } else {
13106            self.admit_provider_runtime(&provider, runtime_requirement).await?
13107        };
13108        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13109        // The single point where this session's `runtime_policy` becomes
13110        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13111        // branch above) only ever compute a PTY-terminal-verification-shaped
13112        // value, sound outright only for `TransportKind::Pty` (Pipe still
13113        // relies on that same value downstream -- see `policy_for_transport`'s
13114        // own doc for why). `policy_for_transport` is the explicit,
13115        // exhaustive match Item 1 asks for -- this is the ONE place its
13116        // output becomes the session's `runtime_policy`, so every later
13117        // reader (`ControlCommand::Start`, `session_bindings`, and every
13118        // engine gate that reads `state.runtime_policy`) sees the same,
13119        // correctly-computed value.
13120        let runtime_policy =
13121            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13122        if required_capabilities
13123            .iter()
13124            .any(|capability| !runtime_policy.admits(*capability))
13125        {
13126            return Err(failure(
13127                NodeFailureCode::UnsupportedSpawnCapability,
13128                "provider runtime does not admit a required spawn capability",
13129            ));
13130        }
13131        if let Some(deadline) = deadline {
13132            spawn_deadline_remaining(deadline)?;
13133        }
13134        self.ensure_binding_capacity()?;
13135        let mut working_directory = self.workspace_root(&workspace_id)?;
13136        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13137        let session = SessionKey {
13138            instance_id,
13139            generation: SessionGeneration(1),
13140        };
13141        let address = SessionAddress {
13142            workspace_id: workspace_id.clone(),
13143            session,
13144        };
13145        // This is the request-accepted checkpoint: admission (managed
13146        // worktree authority, runtime capability, dispatch deadline) already
13147        // passed and a concrete session identity is minted. It is a distinct
13148        // state from the process actually running (`ControlEventKind::Running`,
13149        // logged in `publish_control`) or the terminal outcome (`Exited`/
13150        // `Failed`, logged there too) -- this line exists so that gap is no
13151        // longer silent.
13152        tracing::info!(
13153            node_id = %self.node_id,
13154            workspace_id = %workspace_id,
13155            provider = %provider,
13156            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13157            session = ?address.session,
13158            "session spawn request accepted",
13159        );
13160        let mut prepared_spawn = SpawnPreparer::prepare(
13161            self,
13162            SpawnPrepareRequest {
13163                instance_id,
13164                address: &address,
13165                provider: &provider,
13166                mode,
13167                environment_profile: environment_profile.as_ref(),
13168                bundle: bundle.as_ref(),
13169                context: context.as_ref(),
13170                managed_authority: managed_authority.as_ref(),
13171                harness_mcp,
13172                approval_level,
13173                network_allowlist: network_allowlist.as_ref(),
13174                browser_profile_id,
13175            },
13176        )?;
13177        let materialization_id = prepared_spawn.materialization_id.take();
13178        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13179        let record_id = self.bind_spawn_session_with_materialization(
13180            &address,
13181            provider.clone(),
13182            mode,
13183            runtime_policy,
13184            record_policy,
13185            environment_profile,
13186            bundle,
13187            context,
13188            materialization_id,
13189        )?;
13190        prepared_spawn.retain_session_guards();
13191        // `transport` was already computed above, alongside this session's
13192        // `runtime_policy`; reused here for `Register` rather than derived a
13193        // second time.
13194        let session_provider = provider.clone();
13195        let agent_id = provider;
13196        let register_command_id = match self.dispatch_bounded(
13197            ControlCommand::Register {
13198                instance_id,
13199                agent_id,
13200                transport,
13201            },
13202            dispatch_timeout,
13203        )
13204        .await {
13205            Ok(command_id) => command_id,
13206            Err(error) => {
13207                if let Some(binding) = self.remove_binding(&address) {
13208                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13209                }
13210                if let Some(record_id) = record_id.as_ref() {
13211                    self.discard_record(record_id)?;
13212                }
13213                return Err(spawn_dispatch_error(error, deadline));
13214            }
13215        };
13216        let start_timeout = match spawn_dispatch_timeout(deadline) {
13217            Ok(timeout) => timeout,
13218            Err(error) => {
13219                let recovery = self
13220                    .rollback_spawn(
13221                        &address,
13222                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13223                    )
13224                    .await;
13225                return match recovery {
13226                    Ok(()) => {
13227                        if let Some(record_id) = record_id.as_ref() {
13228                            self.discard_record(record_id)?;
13229                        }
13230                        Err(error)
13231                    }
13232                    Err(recovery_error) => {
13233                        if let Some(record_id) = record_id.as_ref() {
13234                            self.mark_record_error(
13235                                record_id,
13236                                "spawn deadline elapsed and registration recovery failed",
13237                            )?;
13238                        }
13239                        Err(recovery_error)
13240                    }
13241                };
13242            }
13243        };
13244        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13245            if let Err(recovery_error) = self
13246                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13247                .await
13248            {
13249                return Err(recovery_error);
13250            }
13251            if let Some(record_id) = record_id.as_ref() {
13252                self.discard_record(record_id)?;
13253            }
13254            return Err(start_error);
13255        }
13256        // A session with its own m4a account runs in its own folder inside the workspace.
13257        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13258            working_directory = dir.to_string_lossy().into_owned();
13259        }
13260        let start_result = self
13261            .dispatch_bounded(
13262                ControlCommand::Start {
13263                    instance_id,
13264                    runtime_policy,
13265                    request: StartRequest {
13266                        working_directory,
13267                        terminal_size,
13268                        initial_prompt,
13269                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13270                        approval_level,
13271                    },
13272                },
13273                start_timeout,
13274            )
13275            .await;
13276        let start_command_id = match start_result {
13277            Ok(command_id) => command_id,
13278            Err(start_error) => {
13279                let start_error = spawn_dispatch_error(start_error, deadline);
13280                let recovery = self
13281                    .rollback_spawn(
13282                        &address,
13283                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13284                    )
13285                    .await;
13286                return match recovery {
13287                    Ok(()) => {
13288                        if let Some(record_id) = record_id.as_ref() {
13289                            self.discard_record(record_id)?;
13290                        }
13291                        Err(start_error)
13292                    }
13293                    Err(recovery_error) => {
13294                        let diagnostic = format!(
13295                            "start failed and registration recovery failed: {}",
13296                            recovery_error.message,
13297                        );
13298                        if let Some(record_id) = record_id.as_ref() {
13299                            self.mark_record_error(record_id, &diagnostic)?;
13300                        }
13301                        Err(failure(recovery_error.code, &diagnostic))
13302                    }
13303                };
13304            }
13305        };
13306
13307        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13308        loop {
13309            // `Register`/`Start` only fail synchronously for a port-level
13310            // ingress problem (see `dispatch_bounded`); the kernel refusing
13311            // either command's content -- an unsupported transport, an
13312            // unknown agent -- lands one drive-loop tick later, and no
13313            // session ever appears in `self.handle.snapshot()` for it to
13314            // notice below. Check for that named reason before falling
13315            // through to the blind presence poll, so a rejected spawn
13316            // reports why instead of just timing out.
13317            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13318                .or_else(|| self.take_rejected_command_reason(start_command_id))
13319            {
13320                self.rollback_spawn(
13321                    &address,
13322                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13323                ).await.map_err(|error| {
13324                        failure(
13325                            error.code,
13326                            &format!(
13327                                "spawn was rejected ({}) and registration recovery failed: {}",
13328                                reason.message, error.message
13329                            ),
13330                        )
13331                    })?;
13332                if let Some(record_id) = record_id.as_ref() {
13333                    self.discard_record(record_id)?;
13334                }
13335                let code = if reason.unsupported_transport {
13336                    NodeFailureCode::UnsupportedTransport
13337                } else {
13338                    NodeFailureCode::BackendOperationFailed
13339                };
13340                return Err(failure(code, &reason.message));
13341            }
13342            if let Some(current) = self
13343                .handle
13344                .snapshot()
13345                .sessions
13346                .iter()
13347                .find(|current| current.instance_id == instance_id)
13348            {
13349                if current.generation != session.generation {
13350                    let divergence = failure(
13351                        NodeFailureCode::StaleGeneration,
13352                        "spawn generation diverged",
13353                    );
13354                    return match self
13355                        .rollback_spawn(
13356                            &address,
13357                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13358                        )
13359                        .await
13360                    {
13361                        Ok(()) => {
13362                            if let Some(record_id) = record_id.as_ref() {
13363                                self.discard_record(record_id)?;
13364                            }
13365                            Err(divergence)
13366                        }
13367                        Err(recovery_error) => {
13368                            if let Some(record_id) = record_id.as_ref() {
13369                                self.mark_record_error(
13370                                    record_id,
13371                                    "spawn generation diverged and recovery failed",
13372                                )?;
13373                            }
13374                            Err(recovery_error)
13375                        }
13376                    };
13377                }
13378                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13379                    prepared_spawn.retain_launch_guards();
13380                    return Ok((address, runtime_policy));
13381                }
13382            }
13383            if Instant::now() >= commit_deadline {
13384                self.rollback_spawn(
13385                    &address,
13386                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13387                ).await.map_err(|error| {
13388                        failure(
13389                            error.code,
13390                            &format!(
13391                                "start did not commit and registration recovery failed: {}",
13392                                error.message
13393                            ),
13394                        )
13395                    })?;
13396                if let Some(record_id) = record_id.as_ref() {
13397                    self.discard_record(record_id)?;
13398                }
13399                return Err(failure(
13400                    if deadline.is_some() {
13401                        NodeFailureCode::SpawnDeadlineExceeded
13402                    } else {
13403                        NodeFailureCode::BackendBusy
13404                    },
13405                    "start did not commit before the bounded deadline; registration was removed",
13406                ));
13407            }
13408            sleep(Duration::from_millis(2)).await;
13409        }
13410    }
13411
13412    fn effective_spawn_runtime_policy(
13413        &self,
13414        runtime_policy: ProviderRuntimePolicy,
13415    ) -> ProviderRuntimePolicy {
13416        #[cfg(feature = "fixture")]
13417        let runtime_policy = if self.fixture_semantic_hook_policy {
13418            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13419                .expect("monitoring Hook fixture policy is internally valid")
13420        } else {
13421            runtime_policy
13422        };
13423        runtime_policy
13424    }
13425
13426    fn verify_harness_mcp_before_start(
13427        &self,
13428        prepared: Option<&PreparedHarnessMcpSpawn>,
13429    ) -> Result<(), NodeFailure> {
13430        prepared
13431            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13432            .transpose()
13433            .map(|_| ())
13434            .map_err(harness_mcp_failure)
13435    }
13436
13437    async fn rollback_spawn(
13438        &self,
13439        address: &SessionAddress,
13440        commit_timeout: Duration,
13441    ) -> Result<(), NodeFailure> {
13442        self.dispatch_bounded(
13443            ControlCommand::Remove {
13444                instance_id: address.session.instance_id,
13445            },
13446            commit_timeout,
13447        )
13448        .await?;
13449        self.wait_until_removed(address, commit_timeout).await?;
13450        if let Some(binding) = self.remove_binding(address) {
13451            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13452        }
13453        Ok(())
13454    }
13455
13456    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13457        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13458        self.dispatch_bounded(
13459            ControlCommand::Remove {
13460                instance_id: address.session.instance_id,
13461            },
13462            commit_timeout,
13463        )
13464            .await?;
13465        self.wait_until_removed(address, commit_timeout).await?;
13466        let removed = self.remove_binding(address);
13467        if let Some(binding) = removed.as_ref() {
13468            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13469        }
13470        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13471            let snapshot = self.managed_worktrees.lock()
13472                .unwrap_or_else(|poisoned| poisoned.into_inner())
13473                .release_session(
13474                    &lease_id,
13475                    self.incarnation_id,
13476                    address.session.instance_id,
13477                    address.session.generation,
13478                    unix_time_ms(),
13479                );
13480            if self.persist_state().is_err() {
13481                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13482            }
13483            if let Some(snapshot) = snapshot {
13484                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13485            }
13486            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13487        }
13488        Ok(())
13489    }
13490
13491    /// Polls until `address`'s session leaves the engine's own session map,
13492    /// or the bounded deadline expires.
13493    ///
13494    /// A `Remove` sent while the session is not yet in a terminal status
13495    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13496    /// once by the engine (`SessionStatus::allows_remove`) and
13497    /// then discarded -- nothing re-issues it on its own, so the removal
13498    /// that would satisfy this wait never arrives and the caller was
13499    /// guaranteed to time out even though the session settles microseconds
13500    /// later. So this re-dispatches on every poll tick: a session that
13501    /// settles mid-wait -- the common case right after a `Stop` -- still
13502    /// gets removed inside the same bounded deadline.
13503    ///
13504    /// It re-dispatches only once the status will ACCEPT the command. A
13505    /// rejected re-dispatch is not the harmless no-op this loop first
13506    /// assumed: every rejection publishes a `ControlEventKind::Command
13507    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13508    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13509    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13510    /// out to the harness and every attached client. Gated, the same
13511    /// teardown sends exactly one `Remove`, at the first tick the engine
13512    /// can act on it.
13513    ///
13514    /// The status is read from the snapshot this loop already takes, so
13515    /// the gate costs nothing extra. It races -- the status can move
13516    /// between the read and the dispatch -- and that is fine: a rare lost
13517    /// race is one rejection, and the next tick retries.
13518    ///
13519    /// Re-dispatch is separately rate-limited to
13520    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13521    /// leaves a second burst behind it. Once the session settles, every
13522    /// 2ms tick sent another `Remove` while the accepted one was still in
13523    /// flight, and each of those landed on an instance the engine had
13524    /// already dropped -- `agent instance … is not registered`, observed
13525    /// live as a tail of rejections immediately after the `session
13526    /// removed` line. The presence poll stays at 2ms so the wait still
13527    /// exits promptly; only the sending slows down.
13528    ///
13529    /// Rate-limited rather than once-only on purpose: the status gate
13530    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13531    /// others (a pending operation, an in-flight capability probe or
13532    /// history request) that clear on their own. A single shot would turn
13533    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13534    /// get their retries, just twenty of them across the deadline instead
13535    /// of a thousand.
13536    async fn wait_until_removed(
13537        &self,
13538        address: &SessionAddress,
13539        commit_timeout: Duration,
13540    ) -> Result<(), NodeFailure> {
13541        let deadline = Instant::now() + commit_timeout;
13542        let mut next_dispatch = Instant::now();
13543        loop {
13544            let snapshot = self.handle.snapshot();
13545            let status = snapshot
13546                .sessions
13547                .iter()
13548                .find(|session| session.instance_id == address.session.instance_id)
13549                .map(|session| session.status.clone());
13550            let Some(status) = status else {
13551                return Ok(());
13552            };
13553            let now = Instant::now();
13554            if now >= deadline {
13555                return Err(failure(
13556                    NodeFailureCode::BackendBusy,
13557                    "remove did not commit before the bounded deadline; session binding was retained",
13558                ));
13559            }
13560            if status.allows_remove() && now >= next_dispatch {
13561                let _ = self.dispatch(ControlCommand::Remove {
13562                    instance_id: address.session.instance_id,
13563                });
13564                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13565            }
13566            sleep(Duration::from_millis(2)).await;
13567        }
13568    }
13569
13570    fn resync(&self, after_sequence: u64) -> NodeResponse {
13571        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13572        let event_sequence = history.last_sequence;
13573        let oldest_available_sequence = history.replay_floor_sequence;
13574        let events = history
13575            .events
13576            .iter()
13577            .filter(|event| event.sequence > after_sequence)
13578            .cloned()
13579            .collect();
13580        drop(history);
13581        NodeResponse::Resync {
13582            event_sequence,
13583            oldest_available_sequence,
13584            snapshot: self.snapshot(),
13585            events,
13586        }
13587    }
13588}
13589
13590/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13591/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13592/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13593/// "name what crossed this boundary" reasoning, one layer upstream of it).
13594fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13595    match kind {
13596        AgentStreamChunkKindV1::Text { .. } => "text",
13597        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13598        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13599        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13600        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13601        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13602        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13603    }
13604}
13605
13606/// Serves this wire on the local endpoint, and -- when `call_home` names a
13607/// relay -- over an outbound connection to it as well.
13608///
13609/// The two live in one future rather than a fourth arm of `run`'s own
13610/// `select!` because they are one concern: this is where the node's wire
13611/// is served, and the direction a given connection was opened in does not
13612/// change what happens on it. It also means the existing graceful drain
13613/// below covers both without being duplicated.
13614///
13615/// Either side finishing ends the pair, which is correct: the listener
13616/// only returns on shutdown or a bind failure, and `call_home_loop` only
13617/// returns on shutdown -- a failed relay connection is retried inside it,
13618/// never propagated out.
13619async fn accept_connections(
13620    endpoint: &str,
13621    call_home: Option<std::net::SocketAddr>,
13622    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13623    shared: Arc<NodeShared>,
13624) -> Result<(), NodeServerError> {
13625    let mut connections = JoinSet::new();
13626    let result = match call_home {
13627        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13628        Some(relay) => {
13629            let listener =
13630                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13631            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13632            tokio::pin!(listener);
13633            tokio::pin!(dialler);
13634            tokio::select! {
13635                result = &mut listener => result,
13636                result = &mut dialler => result,
13637            }
13638        }
13639    };
13640    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13641    while !connections.is_empty() {
13642        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13643        if remaining.is_zero()
13644            || timeout(remaining, connections.join_next()).await.is_err()
13645        {
13646            break;
13647        }
13648    }
13649    connections.abort_all();
13650    while connections.join_next().await.is_some() {}
13651    result
13652}
13653
13654/// Backoff ladder for a relay that will not answer, capped rather than
13655/// unbounded: a node whose relay is down is not broken and should keep
13656/// trying forever, but it should not spend a core doing it.
13657const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13658
13659/// Dials the relay, serves this wire over the connection, and does it
13660/// again when the connection ends.
13661///
13662/// The node is the SERVER on the socket it just opened. That inversion is
13663/// the whole point: `serve_connection` is the same function the local
13664/// listener feeds, and it neither knows nor cares which end placed the
13665/// call. The one thing this direction adds is the preface -- the relay
13666/// accepted a socket and cannot know whose it is, so the node says its
13667/// name before the handshake it would otherwise begin with.
13668///
13669/// Never returns an error. A relay that is down, refusing, or speaking a
13670/// different protocol is a condition to retry, not a reason to take the
13671/// node's local endpoint down with it; the only way out is shutdown.
13672async fn call_home_loop(
13673    relay: std::net::SocketAddr,
13674    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13675    shared: Arc<NodeShared>,
13676) -> Result<(), NodeServerError> {
13677    let mut failures = 0_usize;
13678    loop {
13679        if shared.shutdown.load(Ordering::Acquire) {
13680            return Ok(());
13681        }
13682        let permit = tokio::select! {
13683            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13684                permit.map_err(|_| NodeServerError::PreauthClosed)?
13685            }
13686            _ = shared.shutdown_notify.notified() => return Ok(()),
13687        };
13688        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13689            Ok(()) => {
13690                // A clean end is the relay hanging up, which it does on
13691                // its own restart. Reconnect immediately rather than
13692                // serving the backoff a peer that is plainly there.
13693                failures = 0;
13694                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13695            }
13696            Err(error) => {
13697                failures = failures.saturating_add(1);
13698                tracing::warn!(
13699                    node_id = %shared.node_id,
13700                    %relay,
13701                    cause = %error,
13702                    attempt = failures,
13703                    "call-home connection failed",
13704                );
13705                let backoff = call_home_backoff(failures);
13706                tokio::select! {
13707                    _ = sleep(backoff) => {}
13708                    _ = shared.shutdown_notify.notified() => return Ok(()),
13709                }
13710            }
13711        }
13712    }
13713}
13714
13715/// Idle time before the first keepalive probe on a call-home socket.
13716const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13717/// Interval between keepalive probes once the link has gone quiet.
13718const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13719
13720fn call_home_keepalive() -> socket2::TcpKeepalive {
13721    let keepalive = socket2::TcpKeepalive::new()
13722        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13723        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13724    #[cfg(not(windows))]
13725    let keepalive = keepalive.with_retries(3);
13726    keepalive
13727}
13728
13729fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13730    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13731}
13732
13733/// Same keepalive profile for any node->relay socket (restream uplink).
13734pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13735    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13736}
13737
13738/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13739fn call_home_backoff(attempt: usize) -> Duration {
13740    Duration::from_millis(
13741        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13742    )
13743}
13744
13745
13746/// Dial the call-home relay. Tunnel addresses go through the in-process
13747/// WireGuard dialer when the userspace session is up.
13748async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13749    #[cfg(feature = "wireguard")]
13750    {
13751        let std_stream = tokio::task::spawn_blocking(move || {
13752            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13753        })
13754        .await
13755        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13756        .map_err(NodeServerError::CallHomeTransport)?;
13757        std_stream
13758            .set_nonblocking(true)
13759            .map_err(NodeServerError::CallHomeTransport)?;
13760        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13761    }
13762    #[cfg(not(feature = "wireguard"))]
13763    {
13764        tokio::net::TcpStream::connect(relay)
13765            .await
13766            .map_err(NodeServerError::CallHomeTransport)
13767    }
13768}
13769
13770/// One call-home attempt: connect, name this node, then serve.
13771async fn dial_relay_and_serve(
13772    relay: std::net::SocketAddr,
13773    identity: Option<&hatchery_enroll::NodeIdentity>,
13774    shared: Arc<NodeShared>,
13775    preauth_permit: OwnedSemaphorePermit,
13776) -> Result<(), NodeServerError> {
13777    let mut stream = dial_call_home(relay).await?;
13778    // Nagle off for the same reason the rest of this wire wants it off:
13779    // the traffic is small request/response frames, and coalescing them
13780    // adds latency to every one.
13781    let _ = stream.set_nodelay(true);
13782    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13783    // relay-side socket kill whose RST was lost) would otherwise leave this
13784    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13785    // fails and the reconnect loop never runs. Kernel keepalive turns that
13786    // half-open link into a read error within a bounded time.
13787    if let Err(error) = enable_call_home_keepalive(&stream) {
13788        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13789    }
13790    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13791        .await
13792        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13793    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13794    let mut session_token = None;
13795    if let Some(identity) = identity {
13796        // An enrolled relay answers the announce with a signed-call-home challenge; a
13797        // token-mode relay sends its wire hello instead. Peek to tell them apart
13798        // without consuming either.
13799        let mut magic = [0_u8; 4];
13800        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13801            .await
13802            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13803            .map_err(NodeServerError::CallHomeTransport)?;
13804        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13805            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13806            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13807                .await
13808                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13809                .map_err(NodeServerError::CallHomeTransport)?;
13810            let (answer, token) = identity
13811                .answer_challenge(shared.node_id.as_str(), &frame)
13812                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13813            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13814            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13815            session_token = Some(token);
13816        }
13817    }
13818    serve_connection(stream, shared, session_token, preauth_permit).await
13819}
13820
13821async fn accept_connections_inner(
13822    endpoint: &str,
13823    shared: Arc<NodeShared>,
13824    connections: &mut JoinSet<Result<(), NodeServerError>>,
13825) -> Result<(), NodeServerError> {
13826    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13827    loop {
13828        while connections.try_join_next().is_some() {}
13829        if shared.shutdown.load(Ordering::Acquire) {
13830            return Ok(());
13831        }
13832        let preauth_permit = tokio::select! {
13833            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13834                permit.map_err(|_| NodeServerError::PreauthClosed)?
13835            }
13836            _ = shared.shutdown_notify.notified() => return Ok(()),
13837        };
13838        let server = tokio::select! {
13839            result = listener.accept() => result?,
13840            _ = shared.shutdown_notify.notified() => return Ok(()),
13841        };
13842        let connection_shared = Arc::clone(&shared);
13843        connections.spawn(async move {
13844            serve_connection(server, connection_shared, None, preauth_permit).await
13845        });
13846        if shared.shutdown.load(Ordering::Acquire) {
13847            return Ok(());
13848        }
13849    }
13850}
13851
13852/// Serves one connection, whichever end opened it.
13853///
13854/// Generic over the stream rather than typed to `LocalServerStream`
13855/// because a call-home connection arrives on a `TcpStream` the node itself
13856/// dialled. Nothing in the body ever depended on the concrete type -- it
13857/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13858/// the whole of what welding "who dials" to "who serves" was costing.
13859async fn serve_connection<S>(
13860    mut pipe: S,
13861    shared: Arc<NodeShared>,
13862    session_token: Option<String>,
13863    preauth_permit: OwnedSemaphorePermit,
13864) -> Result<(), NodeServerError>
13865where
13866    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13867{
13868    // A signed call-home connection authenticates with its own per-connection token.
13869    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13870    let hello_frame = timeout(
13871        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13872        read_json_frame_limited_body_timeout(
13873            &mut pipe,
13874            MAX_NODE_HELLO_FRAME_BYTES,
13875            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13876        ),
13877    )
13878    .await
13879    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13880    let ClientFrame::Hello(hello) = hello_frame else {
13881        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13882    };
13883    if hello.build_stamp != BUILD_STAMP {
13884        return Err(NodeServerError::Handshake(format!(
13885            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13886            hello.build_stamp,
13887        )));
13888    }
13889    let compatibility = match hello.compatibility.as_ref() {
13890        Some(offer) => {
13891            let mut selected = node_compatibility_support(&shared)?
13892                .negotiate(offer)
13893                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13894            project_negotiated_provider_ids(&mut selected);
13895            Some(selected)
13896        }
13897        None => None,
13898    };
13899    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13900    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13901        (Some(offer), Some(selected)) => negotiated_auth_proof(
13902            auth_token,
13903            AuthDirection::Server,
13904            hello.role,
13905            &hello.client_nonce,
13906            &server_nonce,
13907            offer,
13908            selected,
13909        ),
13910        (None, None) => auth_proof(
13911            auth_token,
13912            AuthDirection::Server,
13913            hello.role,
13914            &hello.client_nonce,
13915            &server_nonce,
13916        ),
13917        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13918    }
13919    .map_err(NodeServerError::Authentication)?;
13920    write_json_frame_limited(
13921        &mut pipe,
13922        &ServerFrame::Challenge(ServerChallenge {
13923            build_stamp: BUILD_STAMP.to_owned(),
13924            server_nonce,
13925            server_proof,
13926            compatibility: compatibility.clone(),
13927        }),
13928        MAX_NODE_HELLO_FRAME_BYTES,
13929    )
13930    .await?;
13931    let authentication = timeout(
13932        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13933        read_json_frame_limited_body_timeout(
13934            &mut pipe,
13935            MAX_NODE_HELLO_FRAME_BYTES,
13936            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13937        ),
13938    )
13939    .await
13940    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13941    let ClientFrame::Authenticate(authentication) = authentication else {
13942        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13943    };
13944    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13945        (Some(offer), Some(selected)) => negotiated_auth_proof(
13946            auth_token,
13947            AuthDirection::Client,
13948            hello.role,
13949            &hello.client_nonce,
13950            &server_nonce,
13951            offer,
13952            selected,
13953        ),
13954        (None, None) => auth_proof(
13955            auth_token,
13956            AuthDirection::Client,
13957            hello.role,
13958            &hello.client_nonce,
13959            &server_nonce,
13960        ),
13961        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13962    }
13963    .map_err(NodeServerError::Authentication)?;
13964    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13965        return Err(NodeServerError::Handshake("access denied".to_owned()));
13966    }
13967    let selected_capabilities = compatibility
13968        .as_ref()
13969        .map(|selected| selected.capabilities.clone())
13970        .unwrap_or_default();
13971    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13972        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13973    });
13974    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13975        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13976    });
13977    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13978        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13979    });
13980    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13981        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13982    });
13983    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13984        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13985    });
13986    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13987        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13988    }) && selected_capabilities.iter().any(|capability| {
13989        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13990    });
13991    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13992        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13993    });
13994    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13995        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13996    });
13997    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13998        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13999    });
14000    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14001        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14002    });
14003    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14004        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14005    });
14006    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14007        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14008    });
14009    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14010        .try_acquire_owned()
14011        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14012    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14013    let _release_guard = ControllerReleaseGuard {
14014        shared: Arc::clone(&shared),
14015        connection_id,
14016    };
14017    drop(preauth_permit);
14018    let _authenticated_permit = authenticated_permit;
14019    let mut event_rx = shared.event_tx.subscribe();
14020    let mut terminal_event_rx = terminal_event_subscription(
14021        &shared,
14022        include_terminal_frame_events,
14023    );
14024    let mut agent_stream_event_rx = agent_stream_event_subscription(
14025        &shared,
14026        include_agent_stream_events,
14027    );
14028    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14029        &shared,
14030        include_harness_mcp_proxy,
14031    );
14032    write_json_frame(
14033        &mut pipe,
14034        &ServerFrame::Hello(NodeHello {
14035            build_stamp: BUILD_STAMP.to_owned(),
14036            incarnation_id: shared.incarnation_id,
14037            connection_id,
14038            role: hello.role,
14039            event_sequence: shared.current_sequence(),
14040            controller: shared.controller_state(),
14041            snapshot: snapshot_for_wire(
14042                &shared,
14043                include_provider_runtime_status,
14044                include_open_provider_ids,
14045                include_spawn_profiles,
14046                include_managed_worktrees,
14047                include_child_environment_profiles,
14048                include_session_bundles,
14049                include_history_context_packs,
14050                include_agent_progress,
14051                include_session_task_correlation,
14052            ),
14053            compatibility,
14054        }),
14055    )
14056    .await?;
14057    let (mut reader, mut writer) = tokio::io::split(pipe);
14058    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14059    let reader_task = tokio::spawn(async move {
14060        loop {
14061            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14062                &mut reader,
14063                MAX_NODE_CLIENT_FRAME_BYTES,
14064                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14065            )
14066            .await;
14067            let terminal = frame.is_err();
14068            if frame_tx.send(frame).await.is_err() || terminal {
14069                break;
14070            }
14071        }
14072    });
14073    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14074    let mut pending_events = Vec::new();
14075    let mut resync_required = false;
14076    let mut discard_events_through = 0_u64;
14077    loop {
14078        // One tick of the serve loop, whether or not it had anything to do.
14079        // Against `connection_events_sent` this separates "busy" from
14080        // "spinning": a loop turning over thousands of times a second while
14081        // sending nothing is not serving, it is burning a core.
14082        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14083        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14084            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14085        for _ in 0..durable_drain_budget {
14086            match event_rx.try_recv() {
14087                Ok(event) => queue_connection_event(
14088                    &mut pending_events,
14089                    event,
14090                    discard_events_through,
14091                ),
14092                Err(broadcast::error::TryRecvError::Empty) => break,
14093                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14094                    resync_required = true;
14095                    break;
14096                }
14097                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14098            }
14099        }
14100        if let Some(receiver) = terminal_event_rx.as_mut() {
14101            match receiver.try_recv() {
14102                Ok(events) => queue_connection_event_batch(
14103                    &mut pending_events,
14104                    &events,
14105                    discard_events_through,
14106                ),
14107                Err(broadcast::error::TryRecvError::Empty) => {}
14108                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14109                    resync_required = true;
14110                }
14111                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14112            }
14113        }
14114        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14115            // Loops up to the same remaining-room budget the durable
14116            // `event_rx` drain above uses, rather than one `try_recv` per
14117            // tick: each successful receive here is itself only ONE
14118            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14119            // sends `vec![envelope]`, never more), so a single-shot drain
14120            // caps this connection's agent-stream throughput at one chunk
14121            // per outer-loop tick regardless of how many are already queued
14122            // -- exactly the kind of self-inflicted stall this crate's own
14123            // durable-event budget exists to avoid.
14124            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14125                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14126            for _ in 0..agent_stream_drain_budget {
14127                match receiver.try_recv() {
14128                    Ok(events) => queue_connection_event_batch(
14129                        &mut pending_events,
14130                        &events,
14131                        discard_events_through,
14132                    ),
14133                    Err(broadcast::error::TryRecvError::Empty) => break,
14134                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14135                        tracing::warn!(
14136                            connection_id,
14137                            dropped,
14138                            "agent stream event subscription lagged; forcing a resync",
14139                        );
14140                        resync_required = true;
14141                        break;
14142                    }
14143                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14144                }
14145            }
14146        }
14147        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14148            // Mirrors the agent-stream drain immediately above: each
14149            // successful receive is one single-envelope batch
14150            // (`publish_transient` always sends `vec![envelope]`), so this
14151            // loops up to the same remaining-room budget rather than
14152            // stopping after one `try_recv`.
14153            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14154                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14155            for _ in 0..harness_mcp_proxy_drain_budget {
14156                match receiver.try_recv() {
14157                    Ok(events) => queue_connection_event_batch(
14158                        &mut pending_events,
14159                        &events,
14160                        discard_events_through,
14161                    ),
14162                    Err(broadcast::error::TryRecvError::Empty) => break,
14163                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14164                        tracing::warn!(
14165                            connection_id,
14166                            dropped,
14167                            "harness MCP proxy event subscription lagged; forcing a resync",
14168                        );
14169                        resync_required = true;
14170                        break;
14171                    }
14172                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14173                }
14174            }
14175        }
14176        if resync_required {
14177            let event = resync_required_event(&shared);
14178            apply_connection_resync_watermark(
14179                &mut pending_events,
14180                &mut discard_events_through,
14181                event.sequence,
14182            );
14183            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14184            resync_required = false;
14185            continue;
14186        }
14187        if !pending_events.is_empty() {
14188            pending_events.sort_unstable_by_key(|event| event.sequence);
14189            let burst_len = connection_event_burst_len(pending_events.len());
14190            shared
14191                .connection_events_sent
14192                .fetch_add(burst_len as u64, Ordering::Relaxed);
14193            for event in pending_events.drain(..burst_len) {
14194                let event = if include_managed_worktrees {
14195                    Some(event)
14196                } else {
14197                    project_event_without_managed_worktrees(event)
14198                };
14199                let event = event.and_then(|event| if include_open_provider_ids {
14200                    Some(event)
14201                } else {
14202                    project_event_legacy_provider_ids(&shared, event)
14203                });
14204                let event = event.map(|event| if include_child_environment_profiles {
14205                    event
14206                } else {
14207                    project_event_without_child_environment_profile(event)
14208                });
14209                let event = event.map(|event| if include_session_bundles {
14210                    event
14211                } else {
14212                    project_event_without_session_bundle(event)
14213                });
14214                let event = event.map(|event| {
14215                    project_event_history_for_wire(event, include_history_context_packs)
14216                });
14217                let event = event.map(|event| if include_history_context_packs {
14218                    event
14219                } else {
14220                    project_event_without_context_pack(event)
14221                });
14222                let event = event.map(|event| if include_session_task_correlation {
14223                    event
14224                } else {
14225                    project_event_without_session_task_binding(event)
14226                });
14227                let exact_harness_controller = hello.role == ClientRole::Operator
14228                    && shared.controller_state().is_some_and(|controller| {
14229                        controller.connection_id == connection_id
14230                    });
14231                let event = event.and_then(|event| {
14232                    if include_harness_mcp_proxy && exact_harness_controller {
14233                        Some(event)
14234                    } else {
14235                        project_event_without_harness_mcp_proxy(event)
14236                    }
14237                });
14238                if let Some(event) = event {
14239                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14240                }
14241            }
14242        }
14243        tokio::select! {
14244            biased;
14245            frame = frame_rx.recv() => {
14246                let frame = match frame {
14247                    Some(frame) => frame,
14248                    None => break,
14249                };
14250                let frame = match frame {
14251                    Ok(frame) => frame,
14252                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14253                    Err(error) => return Err(error.into()),
14254                };
14255                let ClientFrame::Request(request) = frame else {
14256                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14257                };
14258                #[cfg(feature = "fixture")]
14259                let probe_spawn_managed_worktree_v2 = matches!(
14260                    &request.request,
14261                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14262                );
14263                let requires_open_provider_ids =
14264                    request_requires_open_provider_ids(&shared, &request.request);
14265                let requires_child_environment_profile =
14266                    request_requires_child_environment_profile(&shared, &request.request);
14267                let requires_session_bundle =
14268                    request_requires_session_bundle(&shared, &request.request);
14269                let requires_history_context_pack =
14270                    request_requires_history_context_pack(&shared, &request.request);
14271                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14272                    ResponseEnvelope {
14273                        request_id: request.request_id,
14274                        result: Err(failure(
14275                            NodeFailureCode::UnsupportedCapability,
14276                            "open provider IDs were not negotiated",
14277                        )),
14278                    }
14279                } else if requires_child_environment_profile
14280                    && !include_child_environment_profiles
14281                {
14282                    ResponseEnvelope {
14283                        request_id: request.request_id,
14284                        result: Err(failure(
14285                            NodeFailureCode::UnsupportedCapability,
14286                            "child environment profiles were not negotiated",
14287                        )),
14288                    }
14289                } else if requires_session_bundle && !include_session_bundles {
14290                    ResponseEnvelope {
14291                        request_id: request.request_id,
14292                        result: Err(failure(
14293                            NodeFailureCode::UnsupportedCapability,
14294                            "session bundles were not negotiated",
14295                        )),
14296                    }
14297                } else if requires_history_context_pack && !include_history_context_packs {
14298                    ResponseEnvelope {
14299                        request_id: request.request_id,
14300                        result: Err(failure(
14301                            NodeFailureCode::UnsupportedCapability,
14302                            "history context packs were not negotiated",
14303                        )),
14304                    }
14305                } else if request_uses_unnegotiated_capability(
14306                    &request.request,
14307                    &selected_capabilities,
14308                )
14309                {
14310                    ResponseEnvelope {
14311                        request_id: request.request_id,
14312                        result: Err(failure(
14313                            NodeFailureCode::UnsupportedCapability,
14314                            "request capability was not negotiated",
14315                        )),
14316                    }
14317                } else {
14318                    process_request(&shared, connection_id, hello.role, request).await
14319                };
14320                #[cfg(feature = "fixture")]
14321                if probe_spawn_managed_worktree_v2 {
14322                    shared
14323                        .fixture_spawn_managed_worktree_v2_failure_probe
14324                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14325                }
14326                if !include_provider_runtime_status {
14327                    clear_response_provider_runtime_status(&mut reply);
14328                }
14329                if !include_managed_worktrees {
14330                    project_response_without_managed_worktrees(&mut reply);
14331                }
14332                if !include_open_provider_ids {
14333                    project_response_legacy_provider_ids(&shared, &mut reply);
14334                }
14335                if !include_child_environment_profiles {
14336                    project_response_without_child_environment_profile(&mut reply);
14337                }
14338                if !include_session_bundles {
14339                    project_response_without_session_bundle(&mut reply);
14340                }
14341                project_response_history_for_wire(
14342                    &mut reply,
14343                    include_history_context_packs,
14344                );
14345                if !include_history_context_packs {
14346                    project_response_without_context_pack(&mut reply);
14347                }
14348                if !include_agent_progress {
14349                    project_response_without_agent_progress(&mut reply);
14350                }
14351                if !include_session_task_correlation {
14352                    project_response_without_session_task_binding(&mut reply);
14353                }
14354                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14355            }
14356            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14357            event = event_rx.recv() => {
14358                match event {
14359                    Ok(event) => queue_connection_event(
14360                        &mut pending_events,
14361                        event,
14362                        discard_events_through,
14363                    ),
14364                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14365                    Err(broadcast::error::RecvError::Closed) => break,
14366                }
14367            }
14368            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14369                match events {
14370                    Ok(events) => queue_connection_event_batch(
14371                        &mut pending_events,
14372                        &events,
14373                        discard_events_through,
14374                    ),
14375                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14376                    Err(broadcast::error::RecvError::Closed) => break,
14377                }
14378            }
14379            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14380                match events {
14381                    Ok(events) => queue_connection_event_batch(
14382                        &mut pending_events,
14383                        &events,
14384                        discard_events_through,
14385                    ),
14386                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14387                    Err(broadcast::error::RecvError::Closed) => break,
14388                }
14389            }
14390            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14391                match events {
14392                    Ok(events) => queue_connection_event_batch(
14393                        &mut pending_events,
14394                        &events,
14395                        discard_events_through,
14396                    ),
14397                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14398                    Err(broadcast::error::RecvError::Closed) => break,
14399                }
14400            }
14401            _ = shared.shutdown_notify.notified() => break,
14402        }
14403    }
14404    Ok(())
14405}
14406
14407async fn receive_terminal_event_batch(
14408    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14409) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14410    match receiver {
14411        Some(receiver) => receiver.recv().await,
14412        None => std::future::pending().await,
14413    }
14414}
14415
14416fn terminal_event_subscription(
14417    shared: &NodeShared,
14418    enabled: bool,
14419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14420    enabled.then(|| shared.terminal_event_tx.subscribe())
14421}
14422
14423/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14424/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14425/// this needs a dedicated channel rather than a flag on the terminal one.
14426fn agent_stream_event_subscription(
14427    shared: &NodeShared,
14428    enabled: bool,
14429) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14430    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14431}
14432
14433/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14434/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14435/// own doc for why this needs a dedicated channel rather than a ride on
14436/// the durable `event_tx`.
14437fn harness_mcp_proxy_event_subscription(
14438    shared: &NodeShared,
14439    enabled: bool,
14440) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14441    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14442}
14443
14444fn queue_connection_event(
14445    pending: &mut Vec<NodeEventEnvelope>,
14446    event: NodeEventEnvelope,
14447    discard_through: u64,
14448) {
14449    if event.sequence <= discard_through
14450        && !event.event.requires_harness_mcp_proxy_capability()
14451    {
14452        return;
14453    }
14454    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14455        if let Some(index) = pending.iter().position(|current| {
14456            matches!(
14457                &current.event,
14458                NodeEvent::TerminalFrame {
14459                    address: current_address,
14460                    ..
14461                } if current_address == address
14462            )
14463        }) {
14464            pending.remove(index);
14465        }
14466    }
14467    pending.push(event);
14468}
14469
14470fn project_event_without_harness_mcp_proxy(
14471    envelope: NodeEventEnvelope,
14472) -> Option<NodeEventEnvelope> {
14473    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14474}
14475
14476fn queue_connection_event_batch(
14477    pending: &mut Vec<NodeEventEnvelope>,
14478    events: &[NodeEventEnvelope],
14479    discard_through: u64,
14480) {
14481    for event in events {
14482        queue_connection_event(pending, event.clone(), discard_through);
14483    }
14484}
14485
14486fn apply_connection_resync_watermark(
14487    pending: &mut Vec<NodeEventEnvelope>,
14488    discard_through: &mut u64,
14489    marker_sequence: u64,
14490) {
14491    *discard_through = (*discard_through).max(marker_sequence);
14492    pending.retain(|event| event.sequence > *discard_through);
14493}
14494
14495fn connection_event_burst_len(pending_len: usize) -> usize {
14496    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14497}
14498
14499fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14500    let history = shared
14501        .history
14502        .lock()
14503        .unwrap_or_else(|poisoned| poisoned.into_inner());
14504    let sequence = history.last_sequence;
14505    let oldest_available_sequence = history.replay_floor_sequence;
14506    NodeEventEnvelope {
14507        sequence,
14508        event: NodeEvent::ResyncRequired {
14509            oldest_available_sequence,
14510        },
14511    }
14512}
14513
14514fn node_compatibility_support(
14515    shared: &NodeShared,
14516) -> Result<NodeCompatibilitySupport, NodeServerError> {
14517    let mut support = node_compatibility_support_for_manifest(
14518        &shared.provider_contracts,
14519        &shared.provider_adapter_contracts,
14520    )?;
14521    if shared
14522        .delivery_store
14523        .lock()
14524        .unwrap_or_else(|poisoned| poisoned.into_inner())
14525        .is_some()
14526    {
14527        support.capabilities.push(
14528            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14529                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14530        );
14531    }
14532    if shared.harness_mcp_registry.is_some() {
14533        support.capabilities.push(
14534            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14535                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14536        );
14537    }
14538    Ok(support)
14539}
14540
14541fn node_compatibility_support_for_manifest(
14542    provider_contracts: &[ProviderContractSupport],
14543    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14544) -> Result<NodeCompatibilitySupport, NodeServerError> {
14545    Ok(NodeCompatibilitySupport {
14546        build_stamp: BUILD_STAMP.to_owned(),
14547        capabilities: baseline_capabilities()?,
14548        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14549        path_semantics: platform::path_semantics(),
14550        local_transport: platform::local_transport(),
14551        state_schema: StateSchemaSupport {
14552            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14553                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14554        },
14555        provider_contracts: provider_contracts.to_vec(),
14556        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14557    })
14558}
14559
14560fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14561    let capabilities = [
14562        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14563        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14564        NODE_REPOSITORY_PATH_CAPABILITY,
14565        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14566        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14567        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14568        NODE_GIT_READ_CAPABILITY,
14569        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14570        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14571        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14572        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14573        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14574        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14575        NODE_ACP_CONTROL_CAPABILITY,
14576        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14577        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14578        NODE_WORKTREE_SELECTION_CAPABILITY,
14579        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14580        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14581        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14582        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14583        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14584        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14585        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14586        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14587        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14588        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14589        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14590        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14591    ]
14592        .into_iter()
14593        .map(|capability| {
14594            CapabilityId::new(capability)
14595                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14596        })
14597        .collect::<Result<Vec<_>, _>>()?;
14598    #[cfg(windows)]
14599    let capabilities = {
14600        let mut capabilities = capabilities;
14601        capabilities.push(
14602            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14603                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14604        );
14605        capabilities
14606    };
14607    Ok(capabilities)
14608}
14609
14610fn request_uses_unnegotiated_capability(
14611    request: &NodeRequest,
14612    selected_capabilities: &[CapabilityId],
14613) -> bool {
14614    let selected = |required: &str| {
14615        selected_capabilities
14616            .iter()
14617            .any(|capability| capability.as_str() == required)
14618    };
14619    request
14620        .required_capability()
14621        .is_some_and(|required| !selected(required))
14622        || (request.requires_worktree_selection_capability()
14623            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14624        || (request.requires_spawn_spec_defaults_overrides_capability()
14625            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14626        || (request.requires_spawn_profile_revision_capability()
14627            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14628        || (request.requires_child_environment_profile_capability()
14629            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14630        || (request.requires_session_bundle_materialization_capability()
14631            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14632        || (request.requires_history_context_pack_capability()
14633            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14634}
14635
14636fn snapshot_for_wire(
14637    shared: &NodeShared,
14638    include_provider_runtime_status: bool,
14639    include_open_provider_ids: bool,
14640    include_spawn_profiles: bool,
14641    include_managed_worktrees: bool,
14642    include_child_environment_profiles: bool,
14643    include_session_bundles: bool,
14644    include_history_context_packs: bool,
14645    include_agent_progress: bool,
14646    include_session_task_correlation: bool,
14647) -> NodeSnapshot {
14648    let mut snapshot = shared.snapshot();
14649    if !include_provider_runtime_status {
14650        snapshot.provider_runtime_statuses.clear();
14651    }
14652    if !include_open_provider_ids {
14653        project_snapshot_legacy_provider_ids(&mut snapshot);
14654    }
14655    if !include_managed_worktrees {
14656        snapshot.managed_worktrees.clear();
14657        for workspace in &mut snapshot.workspaces {
14658            workspace.managed_worktree_profiles = None;
14659        }
14660    }
14661    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14662        if !include_spawn_profiles {
14663            inventory.spawn_profiles = None;
14664        }
14665        if !include_session_bundles {
14666            inventory.bundles = None;
14667        }
14668        if inventory.spawn_profiles.is_none()
14669            && inventory.bundles.is_none()
14670            && inventory.network_allowlists.is_none()
14671        {
14672            snapshot.launch_inventory = None;
14673        }
14674    }
14675    if !include_child_environment_profiles {
14676        clear_snapshot_child_environment_profiles(&mut snapshot);
14677    }
14678    if !include_session_bundles {
14679        clear_snapshot_session_bundles(&mut snapshot);
14680    }
14681    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14682    if !include_history_context_packs {
14683        clear_snapshot_context_packs(&mut snapshot);
14684    }
14685    if !include_agent_progress {
14686        snapshot.agent_progress.clear();
14687    }
14688    if !include_session_task_correlation {
14689        clear_snapshot_session_task_bindings(&mut snapshot);
14690    }
14691    snapshot
14692}
14693
14694fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14695    for record in &mut snapshot.session_records {
14696        record.task_binding = None;
14697    }
14698}
14699
14700fn project_event_without_session_task_binding(
14701    mut envelope: NodeEventEnvelope,
14702) -> NodeEventEnvelope {
14703    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14704        record.task_binding = None;
14705    }
14706    envelope
14707}
14708
14709fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14710    let Ok(response) = reply.result.as_mut() else { return };
14711    match response {
14712        NodeResponse::Snapshot { snapshot, .. } => {
14713            clear_snapshot_session_task_bindings(snapshot);
14714        }
14715        NodeResponse::Resync { snapshot, events, .. } => {
14716            clear_snapshot_session_task_bindings(snapshot);
14717            for event in events {
14718                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14719                    record.task_binding = None;
14720                }
14721            }
14722        }
14723        NodeResponse::SessionRecordUpdated { record }
14724        | NodeResponse::ProviderSessionIndexed { record }
14725        | NodeResponse::NativeSessionIndexed { record, .. }
14726        | NodeResponse::SessionRecordResumed { record, .. } => {
14727            record.task_binding = None;
14728        }
14729        _ => {}
14730    }
14731}
14732
14733fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14734    let Ok(response) = reply.result.as_mut() else { return };
14735    match response {
14736        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14737            snapshot.agent_progress.clear();
14738        }
14739        _ => {}
14740    }
14741}
14742
14743fn project_event_without_managed_worktrees(
14744    mut envelope: NodeEventEnvelope,
14745) -> Option<NodeEventEnvelope> {
14746    if matches!(
14747        envelope.event,
14748        NodeEvent::ManagedWorktreeUpserted { .. }
14749            | NodeEvent::ManagedWorktreeRemoved { .. }
14750    ) {
14751        return None;
14752    }
14753    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14754        workspace.managed_worktree_profiles = None;
14755    }
14756    Some(envelope)
14757}
14758
14759fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14760    if matches!(
14761        reply.result,
14762        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14763            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14764    ) {
14765        reply.result = Err(failure(
14766            NodeFailureCode::UnsupportedCapability,
14767            "managed worktree capability was not negotiated",
14768        ));
14769        return;
14770    }
14771    let Ok(response) = reply.result.as_mut() else { return };
14772    match response {
14773        NodeResponse::Snapshot { snapshot, .. } => {
14774            snapshot.managed_worktrees.clear();
14775            for workspace in &mut snapshot.workspaces {
14776                workspace.managed_worktree_profiles = None;
14777            }
14778        }
14779        NodeResponse::Resync { snapshot, events, .. } => {
14780            snapshot.managed_worktrees.clear();
14781            for workspace in &mut snapshot.workspaces {
14782                workspace.managed_worktree_profiles = None;
14783            }
14784            *events = std::mem::take(events)
14785                .into_iter()
14786                .filter_map(project_event_without_managed_worktrees)
14787                .collect();
14788        }
14789        NodeResponse::WorkspaceRegistered { workspace }
14790        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14791        | NodeResponse::WorktreeCreated { workspace, .. } => {
14792            workspace.managed_worktree_profiles = None;
14793        }
14794        NodeResponse::WorkspaceInspected { inspection } => {
14795            inspection.git.managed_worktree = None;
14796        }
14797        _ => {}
14798    }
14799}
14800
14801fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14802    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14803        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14804    });
14805    if includes_open_provider_ids {
14806        return;
14807    }
14808    compatibility
14809        .provider_contracts
14810        .retain(|contract| provider_id_is_legacy(&contract.provider));
14811    compatibility
14812        .provider_adapter_contracts
14813        .retain(|contract| provider_id_is_legacy(&contract.provider));
14814}
14815
14816fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14817    if let Some(spec) = match request {
14818        NodeRequest::SpawnSpec { spec }
14819        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14820        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14821        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14822        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14823        _ => None,
14824    } {
14825        return shared
14826            .resolve_spawn_spec(spec)
14827            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14828    }
14829    request_requires_open_provider_ids_with(
14830        request,
14831        |session| shared.validate_address(session).ok(),
14832        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14833    )
14834}
14835
14836fn request_requires_child_environment_profile(
14837    shared: &NodeShared,
14838    request: &NodeRequest,
14839) -> bool {
14840    if let Some(spec) = match request {
14841        NodeRequest::SpawnSpec { spec }
14842        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14843        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14844        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14845        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14846        _ => None,
14847    } {
14848        return shared
14849            .resolve_spawn_spec(spec)
14850            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14851    }
14852    match request {
14853        NodeRequest::Resume { session, .. }
14854        | NodeRequest::Prompt { session, .. }
14855        | NodeRequest::Paste { session, .. }
14856        | NodeRequest::Input { session, .. }
14857        | NodeRequest::TerminalBytes { session, .. }
14858        | NodeRequest::TerminalControl { session, .. }
14859        | NodeRequest::Resize { session, .. }
14860        | NodeRequest::Interrupt { session }
14861        | NodeRequest::Stop { session, .. }
14862        | NodeRequest::Remove { session }
14863        | NodeRequest::ResolveInteraction { session, .. }
14864        | NodeRequest::SetSessionMode { session, .. }
14865        | NodeRequest::SetSessionConfigOption { session, .. }
14866        | NodeRequest::SetSessionModel { session, .. }
14867        | NodeRequest::DiscoverHistory { session, .. }
14868        | NodeRequest::LoadHistory { session, .. }
14869        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14870        | NodeRequest::ExportContextPack { session } => shared
14871            .session_bindings
14872            .lock()
14873            .unwrap_or_else(|poisoned| poisoned.into_inner())
14874            .get(&session.session.instance_id)
14875            .filter(|binding| {
14876                binding.workspace_id == session.workspace_id
14877                    && binding.generation == session.session.generation
14878            })
14879            .map_or(true, |binding| binding.environment_profile.is_some()),
14880        NodeRequest::RenameSessionRecord { record_id, .. }
14881        | NodeRequest::SetSessionTask { record_id, .. }
14882        | NodeRequest::ResumeSessionRecord { record_id, .. }
14883        | NodeRequest::ForgetSessionRecord { record_id } => shared
14884            .record(record_id)
14885            .map_or(true, |record| record.environment_profile.is_some()),
14886        NodeRequest::ArmHarnessMcpReservation { .. }
14887        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14888        | NodeRequest::ActivateHarnessMcpReservation { .. }
14889        | NodeRequest::AbortHarnessMcpReservation { .. }
14890        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14891        | NodeRequest::RejectHarnessMcpCall { .. }
14892        | NodeRequest::Snapshot
14893        | NodeRequest::Resync { .. }
14894        | NodeRequest::BeginDeliveryStage { .. }
14895        | NodeRequest::PutDeliveryBlobChunk { .. }
14896        | NodeRequest::CommitDeliveryStage { .. }
14897        | NodeRequest::AbortDeliveryStage { .. }
14898        | NodeRequest::BrowseHostDirectories { .. }
14899        | NodeRequest::InspectWorkspace { .. }
14900        | NodeRequest::ReadWorkspaceFile { .. }
14901        | NodeRequest::WriteWorkspaceFile { .. }
14902        | NodeRequest::CreateWorkspaceFile { .. }
14903        | NodeRequest::CreateWorkspaceDirectory { .. }
14904        | NodeRequest::ReadGitHistory { .. }
14905        | NodeRequest::ReadGitDiff { .. }
14906        | NodeRequest::AcquireController { .. }
14907        | NodeRequest::ReleaseController
14908        | NodeRequest::Spawn { .. }
14909        | NodeRequest::SpawnWithOptions { .. }
14910        | NodeRequest::SpawnSpec { .. }
14911        | NodeRequest::RegisterWorkspace { .. }
14912        | NodeRequest::CreateStandaloneWorkspace { .. }
14913        | NodeRequest::UnregisterWorkspace { .. }
14914        | NodeRequest::CreateWorktree { .. }
14915        | NodeRequest::RemoveWorktree { .. }
14916        | NodeRequest::SpawnManagedWorktree { .. }
14917        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14918        | NodeRequest::CleanupManagedWorktree { .. }
14919        | NodeRequest::IndexProviderSession { .. }
14920        | NodeRequest::IndexNativeSession { .. }
14921        | NodeRequest::CatalogNativeSessions { .. }
14922        | NodeRequest::PageNativeSessions { .. }
14923        | NodeRequest::PreviewNativeSession { .. }
14924        | NodeRequest::PreviewSessionRecord { .. }
14925        | NodeRequest::ForgetContextPack { .. }
14926        | NodeRequest::ResolveDurableContextPack { .. }
14927        | NodeRequest::ReadContextPack { .. }
14928        | NodeRequest::Shutdown => false,
14929    }
14930}
14931
14932fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14933    if let Some(spec) = match request {
14934        NodeRequest::SpawnSpec { spec }
14935        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14936        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14937        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14938        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14939        _ => None,
14940    } {
14941        return shared
14942            .resolve_spawn_spec(spec)
14943            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14944    }
14945    match request {
14946        NodeRequest::Resume { session, .. }
14947        | NodeRequest::Prompt { session, .. }
14948        | NodeRequest::Paste { session, .. }
14949        | NodeRequest::Input { session, .. }
14950        | NodeRequest::TerminalBytes { session, .. }
14951        | NodeRequest::TerminalControl { session, .. }
14952        | NodeRequest::Resize { session, .. }
14953        | NodeRequest::Interrupt { session }
14954        | NodeRequest::Stop { session, .. }
14955        | NodeRequest::Remove { session }
14956        | NodeRequest::ResolveInteraction { session, .. }
14957        | NodeRequest::SetSessionMode { session, .. }
14958        | NodeRequest::SetSessionConfigOption { session, .. }
14959        | NodeRequest::SetSessionModel { session, .. }
14960        | NodeRequest::DiscoverHistory { session, .. }
14961        | NodeRequest::LoadHistory { session, .. }
14962        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14963        | NodeRequest::ExportContextPack { session } => shared
14964            .session_bindings
14965            .lock()
14966            .unwrap_or_else(|poisoned| poisoned.into_inner())
14967            .get(&session.session.instance_id)
14968            .filter(|binding| {
14969                binding.workspace_id == session.workspace_id
14970                    && binding.generation == session.session.generation
14971            })
14972            .map_or(true, |binding| binding.bundle.is_some()),
14973        NodeRequest::RenameSessionRecord { record_id, .. }
14974        | NodeRequest::SetSessionTask { record_id, .. }
14975        | NodeRequest::ResumeSessionRecord { record_id, .. }
14976        | NodeRequest::ForgetSessionRecord { record_id } => shared
14977            .record(record_id)
14978            .map_or(true, |record| record.bundle.is_some()),
14979        NodeRequest::ArmHarnessMcpReservation { .. }
14980        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14981        | NodeRequest::ActivateHarnessMcpReservation { .. }
14982        | NodeRequest::AbortHarnessMcpReservation { .. }
14983        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14984        | NodeRequest::RejectHarnessMcpCall { .. }
14985        | NodeRequest::Snapshot
14986        | NodeRequest::Resync { .. }
14987        | NodeRequest::BeginDeliveryStage { .. }
14988        | NodeRequest::PutDeliveryBlobChunk { .. }
14989        | NodeRequest::CommitDeliveryStage { .. }
14990        | NodeRequest::AbortDeliveryStage { .. }
14991        | NodeRequest::BrowseHostDirectories { .. }
14992        | NodeRequest::InspectWorkspace { .. }
14993        | NodeRequest::ReadWorkspaceFile { .. }
14994        | NodeRequest::WriteWorkspaceFile { .. }
14995        | NodeRequest::CreateWorkspaceFile { .. }
14996        | NodeRequest::CreateWorkspaceDirectory { .. }
14997        | NodeRequest::ReadGitHistory { .. }
14998        | NodeRequest::ReadGitDiff { .. }
14999        | NodeRequest::AcquireController { .. }
15000        | NodeRequest::ReleaseController
15001        | NodeRequest::Spawn { .. }
15002        | NodeRequest::SpawnWithOptions { .. }
15003        | NodeRequest::SpawnSpec { .. }
15004        | NodeRequest::RegisterWorkspace { .. }
15005        | NodeRequest::CreateStandaloneWorkspace { .. }
15006        | NodeRequest::UnregisterWorkspace { .. }
15007        | NodeRequest::CreateWorktree { .. }
15008        | NodeRequest::RemoveWorktree { .. }
15009        | NodeRequest::SpawnManagedWorktree { .. }
15010        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15011        | NodeRequest::CleanupManagedWorktree { .. }
15012        | NodeRequest::IndexProviderSession { .. }
15013        | NodeRequest::IndexNativeSession { .. }
15014        | NodeRequest::CatalogNativeSessions { .. }
15015        | NodeRequest::PageNativeSessions { .. }
15016        | NodeRequest::PreviewNativeSession { .. }
15017        | NodeRequest::PreviewSessionRecord { .. }
15018        | NodeRequest::ForgetContextPack { .. }
15019        | NodeRequest::ResolveDurableContextPack { .. }
15020        | NodeRequest::ReadContextPack { .. }
15021        | NodeRequest::Shutdown => false,
15022    }
15023}
15024
15025fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15026    if request.requires_history_context_pack_capability() {
15027        return true;
15028    }
15029    if let Some(spec) = match request {
15030        NodeRequest::SpawnSpec { spec }
15031        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15032        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15033        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15034        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15035        _ => None,
15036    } {
15037        return shared
15038            .resolve_spawn_spec(spec)
15039            .map_or(true, |resolved| resolved.context_id.is_some());
15040    }
15041    match request {
15042        NodeRequest::Resume { session, .. }
15043        | NodeRequest::Prompt { session, .. }
15044        | NodeRequest::Paste { session, .. }
15045        | NodeRequest::Input { session, .. }
15046        | NodeRequest::TerminalBytes { session, .. }
15047        | NodeRequest::TerminalControl { session, .. }
15048        | NodeRequest::Resize { session, .. }
15049        | NodeRequest::Interrupt { session }
15050        | NodeRequest::Stop { session, .. }
15051        | NodeRequest::Remove { session }
15052        | NodeRequest::ResolveInteraction { session, .. }
15053        | NodeRequest::SetSessionMode { session, .. }
15054        | NodeRequest::SetSessionConfigOption { session, .. }
15055        | NodeRequest::SetSessionModel { session, .. } => shared
15056            .session_bindings
15057            .lock()
15058            .unwrap_or_else(|poisoned| poisoned.into_inner())
15059            .get(&session.session.instance_id)
15060            .filter(|binding| {
15061                binding.workspace_id == session.workspace_id
15062                    && binding.generation == session.session.generation
15063            })
15064            .map_or(true, |binding| binding.context.is_some()),
15065        NodeRequest::RenameSessionRecord { record_id, .. }
15066        | NodeRequest::SetSessionTask { record_id, .. }
15067        | NodeRequest::ResumeSessionRecord { record_id, .. }
15068        | NodeRequest::ForgetSessionRecord { record_id } => shared
15069            .record(record_id)
15070            .map_or(true, |record| record.context.is_some()),
15071        NodeRequest::ArmHarnessMcpReservation { .. }
15072        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15073        | NodeRequest::ActivateHarnessMcpReservation { .. }
15074        | NodeRequest::AbortHarnessMcpReservation { .. }
15075        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15076        | NodeRequest::RejectHarnessMcpCall { .. }
15077        | NodeRequest::Snapshot
15078        | NodeRequest::Resync { .. }
15079        | NodeRequest::BeginDeliveryStage { .. }
15080        | NodeRequest::PutDeliveryBlobChunk { .. }
15081        | NodeRequest::CommitDeliveryStage { .. }
15082        | NodeRequest::AbortDeliveryStage { .. }
15083        | NodeRequest::BrowseHostDirectories { .. }
15084        | NodeRequest::InspectWorkspace { .. }
15085        | NodeRequest::ReadWorkspaceFile { .. }
15086        | NodeRequest::WriteWorkspaceFile { .. }
15087        | NodeRequest::CreateWorkspaceFile { .. }
15088        | NodeRequest::CreateWorkspaceDirectory { .. }
15089        | NodeRequest::ReadGitHistory { .. }
15090        | NodeRequest::ReadGitDiff { .. }
15091        | NodeRequest::AcquireController { .. }
15092        | NodeRequest::ReleaseController
15093        | NodeRequest::Spawn { .. }
15094        | NodeRequest::SpawnWithOptions { .. }
15095        | NodeRequest::SpawnSpec { .. }
15096        | NodeRequest::RegisterWorkspace { .. }
15097        | NodeRequest::CreateStandaloneWorkspace { .. }
15098        | NodeRequest::UnregisterWorkspace { .. }
15099        | NodeRequest::CreateWorktree { .. }
15100        | NodeRequest::RemoveWorktree { .. }
15101        | NodeRequest::SpawnManagedWorktree { .. }
15102        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15103        | NodeRequest::CleanupManagedWorktree { .. }
15104        | NodeRequest::IndexProviderSession { .. }
15105        | NodeRequest::IndexNativeSession { .. }
15106        | NodeRequest::CatalogNativeSessions { .. }
15107        | NodeRequest::PageNativeSessions { .. }
15108        | NodeRequest::PreviewNativeSession { .. }
15109        | NodeRequest::PreviewSessionRecord { .. }
15110        | NodeRequest::DiscoverHistory { .. }
15111        | NodeRequest::LoadHistory { .. }
15112        | NodeRequest::ExportContextPackForSessionRecord { .. }
15113        | NodeRequest::ExportContextPack { .. }
15114        | NodeRequest::ForgetContextPack { .. }
15115        | NodeRequest::ResolveDurableContextPack { .. }
15116        | NodeRequest::ReadContextPack { .. }
15117        | NodeRequest::Shutdown => false,
15118    }
15119}
15120
15121fn request_requires_open_provider_ids_with(
15122    request: &NodeRequest,
15123    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15124    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15125) -> bool {
15126    match request {
15127        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15128        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15129        NodeRequest::CatalogNativeSessions { route, .. }
15130        | NodeRequest::PageNativeSessions { route, .. } => {
15131            !provider_id_is_legacy(&route.provider)
15132        }
15133        NodeRequest::PreviewNativeSession { selection, .. }
15134        | NodeRequest::IndexNativeSession { selection, .. } => {
15135            !provider_id_is_legacy(&selection.route.provider)
15136        }
15137        NodeRequest::SpawnSpec { .. }
15138        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15139        NodeRequest::SpawnManagedWorktree { .. }
15140        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15141        NodeRequest::Resume { session, .. }
15142        | NodeRequest::Prompt { session, .. }
15143        | NodeRequest::Paste { session, .. }
15144        | NodeRequest::Input { session, .. }
15145        | NodeRequest::TerminalBytes { session, .. }
15146        | NodeRequest::TerminalControl { session, .. }
15147        | NodeRequest::Resize { session, .. }
15148        | NodeRequest::Interrupt { session }
15149        | NodeRequest::Stop { session, .. }
15150        | NodeRequest::Remove { session }
15151        | NodeRequest::ResolveInteraction { session, .. }
15152        | NodeRequest::SetSessionMode { session, .. }
15153        | NodeRequest::SetSessionConfigOption { session, .. }
15154        | NodeRequest::SetSessionModel { session, .. }
15155        | NodeRequest::DiscoverHistory { session, .. }
15156        | NodeRequest::LoadHistory { session, .. }
15157        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15158        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15159            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15160        NodeRequest::RenameSessionRecord { record_id, .. }
15161        | NodeRequest::SetSessionTask { record_id, .. }
15162        | NodeRequest::ResumeSessionRecord { record_id, .. }
15163        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15164            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15165        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15166            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15167        NodeRequest::ArmHarnessMcpReservation { .. }
15168        | NodeRequest::ActivateHarnessMcpReservation { .. }
15169        | NodeRequest::AbortHarnessMcpReservation { .. }
15170        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15171        | NodeRequest::RejectHarnessMcpCall { .. }
15172        | NodeRequest::Snapshot
15173        | NodeRequest::Resync { .. }
15174        | NodeRequest::BeginDeliveryStage { .. }
15175        | NodeRequest::PutDeliveryBlobChunk { .. }
15176        | NodeRequest::CommitDeliveryStage { .. }
15177        | NodeRequest::AbortDeliveryStage { .. }
15178        | NodeRequest::BrowseHostDirectories { .. }
15179        | NodeRequest::InspectWorkspace { .. }
15180        | NodeRequest::ReadWorkspaceFile { .. }
15181        | NodeRequest::WriteWorkspaceFile { .. }
15182        | NodeRequest::CreateWorkspaceFile { .. }
15183        | NodeRequest::CreateWorkspaceDirectory { .. }
15184        | NodeRequest::ReadGitHistory { .. }
15185        | NodeRequest::ReadGitDiff { .. }
15186        | NodeRequest::AcquireController { .. }
15187        | NodeRequest::ReleaseController
15188        | NodeRequest::RegisterWorkspace { .. }
15189        | NodeRequest::CreateStandaloneWorkspace { .. }
15190        | NodeRequest::UnregisterWorkspace { .. }
15191        | NodeRequest::CreateWorktree { .. }
15192        | NodeRequest::RemoveWorktree { .. }
15193        | NodeRequest::CleanupManagedWorktree { .. }
15194        | NodeRequest::Shutdown => false,
15195        NodeRequest::ForgetContextPack { .. } => true,
15196        // No session/record to cheaply resolve a provider from here either;
15197        // the resolved receipt can carry an open-id provider, so require the
15198        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15199        // is the same digest-keyed lookup one step further into the same
15200        // catalog, so it inherits the same conservative answer.
15201        NodeRequest::ResolveDurableContextPack { .. }
15202        | NodeRequest::ReadContextPack { .. } => true,
15203    }
15204}
15205
15206fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15207    snapshot.enabled_providers.retain(provider_id_is_legacy);
15208    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15209        snapshot
15210            .provider_runtime_statuses
15211            .as_slice()
15212            .iter()
15213            .filter(|status| provider_id_is_legacy(status.provider()))
15214            .cloned(),
15215    )
15216    .expect("a filtered provider runtime projection remains valid");
15217    snapshot
15218        .session_records
15219        .retain(|record| provider_id_is_legacy(&record.provider));
15220    for workspace in &mut snapshot.workspaces {
15221        workspace
15222            .sessions
15223            .retain(|session| provider_id_is_legacy(&session.agent_id));
15224    }
15225}
15226
15227fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15228    for record in &mut snapshot.session_records {
15229        record.environment_profile = None;
15230    }
15231    if let Some(profiles) = snapshot
15232        .launch_inventory
15233        .as_mut()
15234        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15235    {
15236        for profile in profiles {
15237            profile.environment_profile = None;
15238        }
15239    }
15240}
15241
15242fn project_event_without_child_environment_profile(
15243    mut envelope: NodeEventEnvelope,
15244) -> NodeEventEnvelope {
15245    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15246        record.environment_profile = None;
15247    }
15248    envelope
15249}
15250
15251fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15252    let contains_environment_profile = match reply.result.as_ref() {
15253        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15254            receipt.environment_profile.is_some()
15255        }
15256        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15257            receipt.spawn.environment_profile.is_some()
15258        }
15259        _ => false,
15260    };
15261    if contains_environment_profile {
15262        reply.result = Err(failure(
15263            NodeFailureCode::UnsupportedCapability,
15264            "child environment profiles were not negotiated",
15265        ));
15266        return;
15267    }
15268    let Ok(response) = reply.result.as_mut() else {
15269        return;
15270    };
15271    match response {
15272        NodeResponse::Snapshot { snapshot, .. } => {
15273            clear_snapshot_child_environment_profiles(snapshot);
15274        }
15275        NodeResponse::Resync { snapshot, events, .. } => {
15276            clear_snapshot_child_environment_profiles(snapshot);
15277            for event in events {
15278                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15279                    record.environment_profile = None;
15280                }
15281            }
15282        }
15283        NodeResponse::SessionRecordUpdated { record }
15284        | NodeResponse::ProviderSessionIndexed { record }
15285        | NodeResponse::NativeSessionIndexed { record, .. }
15286        | NodeResponse::SessionRecordResumed { record, .. } => {
15287            record.environment_profile = None;
15288        }
15289        NodeResponse::Armed { .. }
15290        | NodeResponse::Spawned { .. }
15291        | NodeResponse::Activated { .. }
15292        | NodeResponse::Aborted { .. }
15293        | NodeResponse::ReplyChunkAccepted { .. }
15294        | NodeResponse::CallRejected { .. }
15295        | NodeResponse::DeliveryStageBegun { .. }
15296        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15297        | NodeResponse::DeliveryCommitted { .. }
15298        | NodeResponse::DeliveryStageAborted { .. }
15299        | NodeResponse::WorkspaceInspected { .. }
15300        | NodeResponse::HostDirectoriesBrowsed { .. }
15301        | NodeResponse::WorkspaceFileRead { .. }
15302        | NodeResponse::WorkspaceFileWritten { .. }
15303        | NodeResponse::WorkspaceFileCreated { .. }
15304        | NodeResponse::WorkspaceDirectoryCreated { .. }
15305        | NodeResponse::GitHistoryRead { .. }
15306        | NodeResponse::GitDiffRead { .. }
15307        | NodeResponse::Controller { .. }
15308        | NodeResponse::SpawnAccepted { .. }
15309        | NodeResponse::SpawnSpecAccepted { .. }
15310        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15311        | NodeResponse::ManagedWorktreeCleanup { .. }
15312        | NodeResponse::NativeSessionsCataloged { .. }
15313        | NodeResponse::NativeSessionsPaged { .. }
15314        | NodeResponse::NativeSessionPreviewed { .. }
15315        | NodeResponse::SessionRecordPreviewed { .. }
15316        | NodeResponse::HistoryDiscovered { .. }
15317        | NodeResponse::HistoryLoaded { .. }
15318        | NodeResponse::ContextPackForSessionRecordExported { .. }
15319        | NodeResponse::ContextPackExported { .. }
15320        | NodeResponse::ContextPackForgotten { .. }
15321        | NodeResponse::DurableContextPackResolved { .. }
15322        | NodeResponse::ContextPackBytesRead { .. }
15323        | NodeResponse::SessionRecordForgotten { .. }
15324        | NodeResponse::WorkspaceRegistered { .. }
15325        | NodeResponse::StandaloneWorkspaceCreated { .. }
15326        | NodeResponse::WorkspaceUnregistered { .. }
15327        | NodeResponse::WorktreeCreated { .. }
15328        | NodeResponse::WorktreeRemoved { .. }
15329        | NodeResponse::Accepted
15330        | NodeResponse::ShuttingDown => {}
15331    }
15332}
15333
15334fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15335    for record in &mut snapshot.session_records {
15336        record.bundle = None;
15337    }
15338}
15339
15340fn project_event_without_session_bundle(
15341    mut envelope: NodeEventEnvelope,
15342) -> NodeEventEnvelope {
15343    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15344        record.bundle = None;
15345    }
15346    envelope
15347}
15348
15349fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15350    let contains_bundle = match reply.result.as_ref() {
15351        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15352        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15353            receipt.spawn.bundle.is_some()
15354        }
15355        _ => false,
15356    };
15357    if contains_bundle {
15358        reply.result = Err(failure(
15359            NodeFailureCode::UnsupportedCapability,
15360            "session bundles were not negotiated",
15361        ));
15362        return;
15363    }
15364    let Ok(response) = reply.result.as_mut() else {
15365        return;
15366    };
15367    match response {
15368        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15369        NodeResponse::Resync { snapshot, events, .. } => {
15370            clear_snapshot_session_bundles(snapshot);
15371            for event in events {
15372                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15373                    record.bundle = None;
15374                }
15375            }
15376        }
15377        NodeResponse::SessionRecordUpdated { record }
15378        | NodeResponse::ProviderSessionIndexed { record }
15379        | NodeResponse::NativeSessionIndexed { record, .. }
15380        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15381        NodeResponse::Armed { .. }
15382        | NodeResponse::Spawned { .. }
15383        | NodeResponse::Activated { .. }
15384        | NodeResponse::Aborted { .. }
15385        | NodeResponse::ReplyChunkAccepted { .. }
15386        | NodeResponse::CallRejected { .. }
15387        | NodeResponse::DeliveryStageBegun { .. }
15388        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15389        | NodeResponse::DeliveryCommitted { .. }
15390        | NodeResponse::DeliveryStageAborted { .. }
15391        | NodeResponse::WorkspaceInspected { .. }
15392        | NodeResponse::HostDirectoriesBrowsed { .. }
15393        | NodeResponse::WorkspaceFileRead { .. }
15394        | NodeResponse::WorkspaceFileWritten { .. }
15395        | NodeResponse::WorkspaceFileCreated { .. }
15396        | NodeResponse::WorkspaceDirectoryCreated { .. }
15397        | NodeResponse::GitHistoryRead { .. }
15398        | NodeResponse::GitDiffRead { .. }
15399        | NodeResponse::Controller { .. }
15400        | NodeResponse::SpawnAccepted { .. }
15401        | NodeResponse::SpawnSpecAccepted { .. }
15402        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15403        | NodeResponse::ManagedWorktreeCleanup { .. }
15404        | NodeResponse::NativeSessionsCataloged { .. }
15405        | NodeResponse::NativeSessionsPaged { .. }
15406        | NodeResponse::NativeSessionPreviewed { .. }
15407        | NodeResponse::SessionRecordPreviewed { .. }
15408        | NodeResponse::HistoryDiscovered { .. }
15409        | NodeResponse::HistoryLoaded { .. }
15410        | NodeResponse::ContextPackForSessionRecordExported { .. }
15411        | NodeResponse::ContextPackExported { .. }
15412        | NodeResponse::ContextPackForgotten { .. }
15413        | NodeResponse::DurableContextPackResolved { .. }
15414        | NodeResponse::ContextPackBytesRead { .. }
15415        | NodeResponse::SessionRecordForgotten { .. }
15416        | NodeResponse::WorkspaceRegistered { .. }
15417        | NodeResponse::StandaloneWorkspaceCreated { .. }
15418        | NodeResponse::WorkspaceUnregistered { .. }
15419        | NodeResponse::WorktreeCreated { .. }
15420        | NodeResponse::WorktreeRemoved { .. }
15421        | NodeResponse::Accepted
15422        | NodeResponse::ShuttingDown => {}
15423    }
15424}
15425
15426fn project_snapshot_history_for_wire(
15427    snapshot: &mut NodeSnapshot,
15428    include_history_metadata: bool,
15429) {
15430    for workspace in &mut snapshot.workspaces {
15431        for session in &mut workspace.sessions {
15432            if include_history_metadata {
15433                session.history.loaded = None;
15434            } else {
15435                session.history = Default::default();
15436            }
15437        }
15438    }
15439}
15440
15441fn project_event_history_for_wire(
15442    mut envelope: NodeEventEnvelope,
15443    include_history_metadata: bool,
15444) -> NodeEventEnvelope {
15445    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15446        for session in &mut workspace.sessions {
15447            if include_history_metadata {
15448                session.history.loaded = None;
15449            } else {
15450                session.history = Default::default();
15451            }
15452        }
15453    }
15454    envelope
15455}
15456
15457fn project_response_history_for_wire(
15458    reply: &mut ResponseEnvelope,
15459    include_history_metadata: bool,
15460) {
15461    let Ok(response) = reply.result.as_mut() else {
15462        return;
15463    };
15464    match response {
15465        NodeResponse::Snapshot { snapshot, .. } => {
15466            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15467        }
15468        NodeResponse::Resync {
15469            snapshot, events, ..
15470        } => {
15471            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15472            for event in events {
15473                *event = project_event_history_for_wire(
15474                    event.clone(),
15475                    include_history_metadata,
15476                );
15477            }
15478        }
15479        NodeResponse::WorkspaceRegistered { workspace }
15480        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15481        | NodeResponse::WorktreeCreated { workspace, .. } => {
15482            for session in &mut workspace.sessions {
15483                if include_history_metadata {
15484                    session.history.loaded = None;
15485                } else {
15486                    session.history = Default::default();
15487                }
15488            }
15489        }
15490        _ => {}
15491    }
15492}
15493
15494fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15495    for record in &mut snapshot.session_records {
15496        record.context_id = None;
15497        record.context = None;
15498        record.exported_context = None;
15499    }
15500}
15501
15502fn project_event_without_context_pack(
15503    mut envelope: NodeEventEnvelope,
15504) -> NodeEventEnvelope {
15505    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15506        record.context_id = None;
15507        record.context = None;
15508        record.exported_context = None;
15509    }
15510    envelope
15511}
15512
15513fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15514    let contains_context = match reply.result.as_ref() {
15515        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15516        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15517            receipt.spawn.context.is_some()
15518        }
15519        Ok(NodeResponse::HistoryDiscovered { .. })
15520        | Ok(NodeResponse::HistoryLoaded { .. })
15521        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15522        | Ok(NodeResponse::ContextPackExported { .. })
15523        | Ok(NodeResponse::ContextPackForgotten { .. })
15524        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15525        _ => false,
15526    };
15527    if contains_context {
15528        reply.result = Err(failure(
15529            NodeFailureCode::UnsupportedCapability,
15530            "history context packs were not negotiated",
15531        ));
15532        return;
15533    }
15534    let Ok(response) = reply.result.as_mut() else {
15535        return;
15536    };
15537    match response {
15538        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15539        NodeResponse::Resync {
15540            snapshot, events, ..
15541        } => {
15542            clear_snapshot_context_packs(snapshot);
15543            for event in events {
15544                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15545                    record.context_id = None;
15546                    record.context = None;
15547                    record.exported_context = None;
15548                }
15549            }
15550        }
15551        NodeResponse::SessionRecordUpdated { record }
15552        | NodeResponse::ProviderSessionIndexed { record }
15553        | NodeResponse::NativeSessionIndexed { record, .. }
15554        | NodeResponse::SessionRecordResumed { record, .. } => {
15555            record.context_id = None;
15556            record.context = None;
15557            record.exported_context = None;
15558        }
15559        _ => {}
15560    }
15561}
15562
15563fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15564    let contains_open_record = match reply.result.as_ref() {
15565        Ok(NodeResponse::SessionRecordUpdated { record })
15566        | Ok(NodeResponse::ProviderSessionIndexed { record })
15567        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15568            !provider_id_is_legacy(&record.provider)
15569        }
15570        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15571            !provider_id_is_legacy(&selection.route.provider)
15572                || !provider_id_is_legacy(&record.provider)
15573        }
15574        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15575            !provider_id_is_legacy(&receipt.provider)
15576        }
15577        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15578            !provider_id_is_legacy(&receipt.spawn.provider)
15579        }
15580        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15581        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15582            !provider_id_is_legacy(&route.provider)
15583        }
15584        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15585            !provider_id_is_legacy(&selection.route.provider)
15586        }
15587        _ => false,
15588    };
15589    if contains_open_record {
15590        reply.result = Err(failure(
15591            NodeFailureCode::UnsupportedCapability,
15592            "open provider IDs were not negotiated",
15593        ));
15594        return;
15595    }
15596    let Ok(response) = reply.result.as_mut() else {
15597        return;
15598    };
15599    match response {
15600        NodeResponse::Snapshot { snapshot, .. } => {
15601            project_snapshot_legacy_provider_ids(snapshot);
15602        }
15603        NodeResponse::Resync { snapshot, events, .. } => {
15604            project_snapshot_legacy_provider_ids(snapshot);
15605            *events = std::mem::take(events)
15606                .into_iter()
15607                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15608                .collect();
15609        }
15610        NodeResponse::WorkspaceRegistered { workspace }
15611        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15612        | NodeResponse::WorktreeCreated { workspace, .. } => {
15613            workspace
15614                .sessions
15615                .retain(|session| provider_id_is_legacy(&session.agent_id));
15616        }
15617        NodeResponse::Armed { .. }
15618        | NodeResponse::Spawned { .. }
15619        | NodeResponse::Activated { .. }
15620        | NodeResponse::Aborted { .. }
15621        | NodeResponse::ReplyChunkAccepted { .. }
15622        | NodeResponse::CallRejected { .. }
15623        | NodeResponse::DeliveryStageBegun { .. }
15624        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15625        | NodeResponse::DeliveryCommitted { .. }
15626        | NodeResponse::DeliveryStageAborted { .. }
15627        | NodeResponse::WorkspaceInspected { .. }
15628        | NodeResponse::HostDirectoriesBrowsed { .. }
15629        | NodeResponse::WorkspaceFileRead { .. }
15630        | NodeResponse::WorkspaceFileWritten { .. }
15631        | NodeResponse::WorkspaceFileCreated { .. }
15632        | NodeResponse::WorkspaceDirectoryCreated { .. }
15633        | NodeResponse::GitHistoryRead { .. }
15634        | NodeResponse::GitDiffRead { .. }
15635        | NodeResponse::Controller { .. }
15636        | NodeResponse::SpawnAccepted { .. }
15637        | NodeResponse::SpawnSpecAccepted { .. }
15638        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15639        | NodeResponse::ManagedWorktreeCleanup { .. }
15640        | NodeResponse::NativeSessionsCataloged { .. }
15641        | NodeResponse::NativeSessionsPaged { .. }
15642        | NodeResponse::NativeSessionPreviewed { .. }
15643        | NodeResponse::SessionRecordPreviewed { .. }
15644        | NodeResponse::HistoryDiscovered { .. }
15645        | NodeResponse::HistoryLoaded { .. }
15646        | NodeResponse::ContextPackForSessionRecordExported { .. }
15647        | NodeResponse::ContextPackExported { .. }
15648        | NodeResponse::ContextPackForgotten { .. }
15649        | NodeResponse::DurableContextPackResolved { .. }
15650        | NodeResponse::ContextPackBytesRead { .. }
15651        | NodeResponse::SessionRecordUpdated { .. }
15652        | NodeResponse::ProviderSessionIndexed { .. }
15653        | NodeResponse::NativeSessionIndexed { .. }
15654        | NodeResponse::SessionRecordResumed { .. }
15655        | NodeResponse::SessionRecordForgotten { .. }
15656        | NodeResponse::WorkspaceUnregistered { .. }
15657        | NodeResponse::WorktreeRemoved { .. }
15658        | NodeResponse::Accepted
15659        | NodeResponse::ShuttingDown => {}
15660    }
15661}
15662
15663fn project_event_legacy_provider_ids(
15664    shared: &NodeShared,
15665    mut envelope: NodeEventEnvelope,
15666) -> Option<NodeEventEnvelope> {
15667    let include = match &mut envelope.event {
15668        NodeEvent::HarnessMcpReadCall { .. } => true,
15669        NodeEvent::Control { address, .. }
15670        | NodeEvent::AgentStream { address, .. }
15671        | NodeEvent::TerminalFrame { address, .. } => shared
15672            .validate_address(address)
15673            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15674        NodeEvent::WorkspaceAdded { workspace } => {
15675            workspace
15676                .sessions
15677                .retain(|session| provider_id_is_legacy(&session.agent_id));
15678            true
15679        }
15680        NodeEvent::SessionRecordUpserted { record } => {
15681            provider_id_is_legacy(&record.provider)
15682        }
15683        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15684        NodeEvent::SessionRecordRemoved { .. } => shared
15685            .history
15686            .lock()
15687            .unwrap_or_else(|poisoned| poisoned.into_inner())
15688            .removed_record_providers
15689            .get(&envelope.sequence)
15690            .is_some_and(provider_id_is_legacy),
15691        NodeEvent::ControllerChanged { .. }
15692        | NodeEvent::ManagedWorktreeUpserted { .. }
15693        | NodeEvent::ManagedWorktreeRemoved { .. }
15694        | NodeEvent::WorkspaceRemoved { .. }
15695        | NodeEvent::ResyncRequired { .. } => true,
15696    };
15697    include.then_some(envelope)
15698}
15699
15700fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15701    let Ok(response) = reply.result.as_mut() else {
15702        return;
15703    };
15704    match response {
15705        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15706            snapshot.provider_runtime_statuses.clear();
15707        }
15708        NodeResponse::Armed { .. }
15709        | NodeResponse::Spawned { .. }
15710        | NodeResponse::Activated { .. }
15711        | NodeResponse::Aborted { .. }
15712        | NodeResponse::ReplyChunkAccepted { .. }
15713        | NodeResponse::CallRejected { .. }
15714        | NodeResponse::DeliveryStageBegun { .. }
15715        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15716        | NodeResponse::DeliveryCommitted { .. }
15717        | NodeResponse::DeliveryStageAborted { .. }
15718        | NodeResponse::WorkspaceInspected { .. }
15719        | NodeResponse::HostDirectoriesBrowsed { .. }
15720        | NodeResponse::WorkspaceFileRead { .. }
15721        | NodeResponse::WorkspaceFileWritten { .. }
15722        | NodeResponse::WorkspaceFileCreated { .. }
15723        | NodeResponse::WorkspaceDirectoryCreated { .. }
15724        | NodeResponse::GitHistoryRead { .. }
15725        | NodeResponse::GitDiffRead { .. }
15726        | NodeResponse::Controller { .. }
15727        | NodeResponse::SpawnAccepted { .. }
15728        | NodeResponse::SpawnSpecAccepted { .. }
15729        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15730        | NodeResponse::ManagedWorktreeCleanup { .. }
15731        | NodeResponse::NativeSessionsCataloged { .. }
15732        | NodeResponse::NativeSessionsPaged { .. }
15733        | NodeResponse::NativeSessionPreviewed { .. }
15734        | NodeResponse::SessionRecordPreviewed { .. }
15735        | NodeResponse::HistoryDiscovered { .. }
15736        | NodeResponse::HistoryLoaded { .. }
15737        | NodeResponse::ContextPackForSessionRecordExported { .. }
15738        | NodeResponse::ContextPackExported { .. }
15739        | NodeResponse::ContextPackForgotten { .. }
15740        | NodeResponse::DurableContextPackResolved { .. }
15741        | NodeResponse::ContextPackBytesRead { .. }
15742        | NodeResponse::SessionRecordUpdated { .. }
15743        | NodeResponse::ProviderSessionIndexed { .. }
15744        | NodeResponse::NativeSessionIndexed { .. }
15745        | NodeResponse::SessionRecordResumed { .. }
15746        | NodeResponse::SessionRecordForgotten { .. }
15747        | NodeResponse::WorkspaceRegistered { .. }
15748        | NodeResponse::StandaloneWorkspaceCreated { .. }
15749        | NodeResponse::WorkspaceUnregistered { .. }
15750        | NodeResponse::WorktreeCreated { .. }
15751        | NodeResponse::WorktreeRemoved { .. }
15752        | NodeResponse::Accepted
15753        | NodeResponse::ShuttingDown => {}
15754    }
15755}
15756
15757struct ControllerReleaseGuard {
15758    shared: Arc<NodeShared>,
15759    connection_id: u64,
15760}
15761
15762impl Drop for ControllerReleaseGuard {
15763    fn drop(&mut self) {
15764        self.shared.release_controller(self.connection_id);
15765    }
15766}
15767
15768struct AbortTaskOnDrop(AbortHandle);
15769
15770impl Drop for AbortTaskOnDrop {
15771    fn drop(&mut self) {
15772        self.0.abort();
15773    }
15774}
15775
15776/// Reads an unsigned env-config override, following the pattern of every
15777/// other bounded knob in this module: missing/unparsable/zero falls back to
15778/// `default`, and the resolved value is always clamped to `max` so a bad
15779/// override cannot reopen a bound this budget exists to enforce.
15780fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15781    std::env::var(name)
15782        .ok()
15783        .and_then(|value| value.parse::<u64>().ok())
15784        .filter(|value| *value > 0)
15785        .unwrap_or(default)
15786        .min(max)
15787}
15788
15789fn workspace_inspection_time_budget_ms() -> u64 {
15790    env_config_bounded(
15791        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15792        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15793        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15794    )
15795}
15796
15797fn workspace_inspection_entry_cap() -> usize {
15798    env_config_bounded(
15799        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15800        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15801        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15802    ) as usize
15803}
15804
15805/// Tracks the shared walk-side budget across the whole recursive walk: a
15806/// wall-clock deadline (shared with the git phase that runs after the walk)
15807/// and a cap on directory entries visited, whether or not they were pushed
15808/// to the response. Cheap to check per entry — one `Instant::now()` and one
15809/// integer compare.
15810struct WorkspaceWalkBudget {
15811    deadline: Instant,
15812    entry_cap: usize,
15813    entries_visited: u64,
15814    time_budget_exceeded: bool,
15815    entry_cap_exceeded: bool,
15816}
15817
15818impl WorkspaceWalkBudget {
15819    fn new(deadline: Instant, entry_cap: usize) -> Self {
15820        Self {
15821            deadline,
15822            entry_cap,
15823            entries_visited: 0,
15824            time_budget_exceeded: false,
15825            entry_cap_exceeded: false,
15826        }
15827    }
15828
15829    fn is_exhausted(&self) -> bool {
15830        self.time_budget_exceeded || self.entry_cap_exceeded
15831    }
15832
15833    /// Records one visited directory entry (pushed to the response or not)
15834    /// and reports whether the walk must stop now.
15835    fn record_visit(&mut self) -> bool {
15836        self.entries_visited += 1;
15837        if self.entries_visited as usize >= self.entry_cap {
15838            self.entry_cap_exceeded = true;
15839        }
15840        if Instant::now() >= self.deadline {
15841            self.time_budget_exceeded = true;
15842        }
15843        self.is_exhausted()
15844    }
15845}
15846
15847fn collect_workspace_entries(
15848    root: &Path,
15849    deadline: Instant,
15850    entry_cap: usize,
15851) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15852    let mut entries = Vec::new();
15853    let mut truncated = false;
15854    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15855    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15856    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15857    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15858    // require strict global byte order over relative paths.
15859    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15860    (entries, truncated, budget)
15861}
15862
15863/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15864/// `inspect_workspace` response — `None` once the walk and git phase both
15865/// completed inside their shared budget.
15866fn workspace_inspection_truncation(
15867    walk_budget: &WorkspaceWalkBudget,
15868    git_time_budget_exceeded: bool,
15869    elapsed: Duration,
15870) -> Option<WorkspaceInspectionTruncationV1> {
15871    if !walk_budget.time_budget_exceeded
15872        && !walk_budget.entry_cap_exceeded
15873        && !git_time_budget_exceeded
15874    {
15875        return None;
15876    }
15877    Some(WorkspaceInspectionTruncationV1 {
15878        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15879        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15880        git_time_budget_exceeded,
15881        entries_visited: walk_budget.entries_visited,
15882        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15883    })
15884}
15885
15886fn walk_workspace_directory(
15887    directory: &Path,
15888    relative_directory: Option<&RepositoryPath>,
15889    depth: usize,
15890    entries: &mut Vec<WorkspaceEntry>,
15891    truncated: &mut bool,
15892    budget: &mut WorkspaceWalkBudget,
15893) {
15894    if budget.is_exhausted() {
15895        *truncated = true;
15896        return;
15897    }
15898    let read_dir = match std::fs::read_dir(directory) {
15899        Ok(read_dir) => read_dir,
15900        Err(_) => {
15901            *truncated = true;
15902            return;
15903        }
15904    };
15905    let mut children = Vec::new();
15906    for child in read_dir {
15907        match child {
15908            Ok(child) => children.push(child),
15909            Err(_) => *truncated = true,
15910        }
15911    }
15912    children.sort_by_key(|child| child.file_name());
15913    for child in children {
15914        if budget.record_visit() {
15915            *truncated = true;
15916            return;
15917        }
15918        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15919            *truncated = true;
15920            return;
15921        }
15922        let file_type = match child.file_type() {
15923            Ok(file_type) => file_type,
15924            Err(_) => {
15925                *truncated = true;
15926                continue;
15927            }
15928        };
15929        if file_type.is_symlink() {
15930            continue;
15931        }
15932        let name = match child.file_name().into_string() {
15933            Ok(name) => name,
15934            Err(_) => {
15935                *truncated = true;
15936                continue;
15937            }
15938        };
15939        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15940            continue;
15941        }
15942        let relative_path = match relative_directory {
15943            None => windows_repository_path(name),
15944            Some(parent) => parent
15945                .as_utf8()
15946                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15947        };
15948        let Some(relative_path) = relative_path else {
15949            *truncated = true;
15950            continue;
15951        };
15952        let kind = if file_type.is_dir() {
15953            WorkspaceEntryKind::Directory
15954        } else if file_type.is_file() {
15955            WorkspaceEntryKind::File
15956        } else {
15957            continue;
15958        };
15959        entries.push(WorkspaceEntry {
15960            relative_path: relative_path.clone(),
15961            kind,
15962        });
15963        if file_type.is_dir() {
15964            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15965                walk_workspace_directory(
15966                    &child.path(),
15967                    Some(&relative_path),
15968                    depth + 1,
15969                    entries,
15970                    truncated,
15971                    budget,
15972                );
15973            } else {
15974                *truncated = true;
15975            }
15976        }
15977    }
15978}
15979
15980fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15981    if value.contains('\\') {
15982        return None;
15983    }
15984    RepositoryPath::utf8(value).ok()
15985}
15986
15987fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15988    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15989}
15990
15991/// Excludes a directory from the workspace tree walk. The three literal
15992/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15993/// beneath them catches every build-output directory regardless of name
15994/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15995/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15996/// directory without maintaining a name heuristic list). The name check
15997/// runs first and returns early so a directory rejected by name never pays
15998/// for the extra `CACHEDIR.TAG` stat.
15999fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16000    if name.eq_ignore_ascii_case(".git")
16001        || name.eq_ignore_ascii_case("target")
16002        || name.eq_ignore_ascii_case("node_modules")
16003    {
16004        return true;
16005    }
16006    path.join("CACHEDIR.TAG").is_file()
16007}
16008
16009fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16010    matches!(
16011        status,
16012        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16013    )
16014}
16015
16016#[derive(Clone, Debug, Eq, PartialEq)]
16017enum ContextPackRepositoryHead {
16018    NotRepository,
16019    Unborn,
16020    Commit(String),
16021}
16022
16023#[derive(Clone, Debug, Eq, PartialEq)]
16024struct ContextPackRepositoryObservation {
16025    head: ContextPackRepositoryHead,
16026    git: GitSnapshot,
16027    files: Vec<ContextPackRepositoryFileSource>,
16028}
16029
16030fn stable_context_pack_repository(
16031    first: ContextPackRepositoryObservation,
16032    second: ContextPackRepositoryObservation,
16033) -> Result<ContextPackRepository, NodeFailure> {
16034    if first != second {
16035        return Err(failure(
16036            NodeFailureCode::ContextPackMaterializationFailed,
16037            "context repository changed during bounded capture",
16038        ));
16039    }
16040    Ok(ContextPackRepository::from_sources(
16041        &second.git,
16042        second.files,
16043    ))
16044}
16045
16046/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16047/// (and only once) the shared walk+git `deadline` has already passed. Every
16048/// remaining phase in `inspect_git_workspace` checks this immediately
16049/// before issuing its own git call, so a budget spent by the walk (or by an
16050/// earlier git call) skips the rest of the chain instead of paying for it
16051/// anyway.
16052fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16053    if Instant::now() < deadline {
16054        return false;
16055    }
16056    append_git_diagnostic(
16057        snapshot,
16058        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16059    );
16060    true
16061}
16062
16063/// Deadline-aware sibling of `run_git_bounded`, used only by
16064/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16065/// whatever remains of the shared `deadline` (never more than
16066/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16067/// of the budget cannot still hand a full per-call timeout to every git
16068/// probe behind it. `inspect_git_workspace` already guards each call site
16069/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16070/// passed yet here.
16071async fn run_git_bounded_with_deadline(
16072    root: &str,
16073    arguments: &[&str],
16074    output_limit: usize,
16075    deadline: Instant,
16076) -> io::Result<GitCommandOutput> {
16077    let remaining = deadline.checked_duration_since(Instant::now());
16078    let timeout_ms = remaining
16079        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16080        .unwrap_or(0)
16081        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16082    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16083}
16084
16085/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16086/// `deadline` is the same absolute instant the preceding directory walk was
16087/// bounded by, so time already spent walking counts against what remains
16088/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16089/// shared budget (not an individual per-call timeout) is what cut the git
16090/// phase short.
16091async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16092    let mut snapshot = GitSnapshot {
16093        is_repository: false,
16094        branch: None,
16095        status: Vec::new(),
16096        recent_commits: Vec::new(),
16097        worktrees: Vec::new(),
16098        managed_worktree: None,
16099        truncated: false,
16100        diagnostic: None,
16101    };
16102    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16103        return (snapshot, true);
16104    }
16105    let repository = match run_git_bounded_with_deadline(
16106        root,
16107        &["rev-parse", "--is-inside-work-tree"],
16108        4 * 1_024,
16109        deadline,
16110    )
16111    .await
16112    {
16113        Ok(output) => output,
16114        Err(error) => {
16115            append_git_diagnostic(
16116                &mut snapshot,
16117                &format!("git inspection unavailable: {error}"),
16118            );
16119            return (snapshot, false);
16120        }
16121    };
16122    snapshot.truncated |= repository.truncated;
16123    if repository.timed_out {
16124        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16125        return (snapshot, false);
16126    }
16127    if !repository.success
16128        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16129    {
16130        let stderr = String::from_utf8_lossy(&repository.stderr);
16131        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16132            append_git_diagnostic(&mut snapshot, stderr.trim());
16133        }
16134        return (snapshot, false);
16135    }
16136    snapshot.is_repository = true;
16137
16138    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16139        return (snapshot, true);
16140    }
16141    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16142        Ok(output) => {
16143            snapshot.truncated |= output.truncated;
16144            if output.timed_out {
16145                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16146            } else if output.success {
16147                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16148                if !branch.is_empty() {
16149                    snapshot.branch = Some(truncate_git_field(
16150                        &branch,
16151                        MAX_REPOSITORY_PATH_BYTES,
16152                        &mut snapshot.truncated,
16153                    ));
16154                }
16155            }
16156        }
16157        Err(error) => append_git_diagnostic(
16158            &mut snapshot,
16159            &format!("git branch query failed: {error}"),
16160        ),
16161    }
16162    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16163        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16164            snapshot.truncated |= output.truncated;
16165            if output.success && !output.timed_out {
16166                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16167                if !head.is_empty() {
16168                    snapshot.branch = Some(format!("detached:{head}"));
16169                }
16170            }
16171        }
16172    }
16173
16174    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16175        return (snapshot, true);
16176    }
16177    match run_git_bounded_with_deadline(
16178        root,
16179        &[
16180            "status",
16181            "--porcelain=v1",
16182            "-z",
16183            "--renames",
16184            "--untracked-files=normal",
16185            "--",
16186            ".",
16187        ],
16188        GIT_OUTPUT_MAX_BYTES,
16189        deadline,
16190    )
16191    .await
16192    {
16193        Ok(output) => {
16194            snapshot.truncated |= output.truncated;
16195            if output.timed_out {
16196                append_git_diagnostic(&mut snapshot, "git status timed out");
16197            } else if output.success {
16198                parse_git_status(&output.stdout, &mut snapshot);
16199            } else {
16200                append_git_diagnostic(
16201                    &mut snapshot,
16202                    String::from_utf8_lossy(&output.stderr).trim(),
16203                );
16204            }
16205        }
16206        Err(error) => append_git_diagnostic(
16207            &mut snapshot,
16208            &format!("git status failed: {error}"),
16209        ),
16210    }
16211
16212    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16213        return (snapshot, true);
16214    }
16215    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16216    match run_git_bounded_with_deadline(
16217        root,
16218        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16219        16 * 1_024,
16220        deadline,
16221    )
16222    .await
16223    {
16224        Ok(output) => {
16225            snapshot.truncated |= output.truncated;
16226            if output.timed_out {
16227                append_git_diagnostic(&mut snapshot, "git log timed out");
16228            } else if output.success {
16229                parse_git_commits(&output.stdout, &mut snapshot);
16230            } else {
16231                let stderr = String::from_utf8_lossy(&output.stderr);
16232                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16233                    append_git_diagnostic(&mut snapshot, stderr.trim());
16234                }
16235            }
16236        }
16237        Err(error) => append_git_diagnostic(
16238            &mut snapshot,
16239            &format!("git log failed: {error}"),
16240        ),
16241    }
16242
16243    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16244        return (snapshot, true);
16245    }
16246    match list_git_worktrees_with_deadline(root, deadline).await {
16247        Ok(worktrees) => {
16248            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16249        }
16250        Err(error) => append_git_diagnostic(
16251            &mut snapshot,
16252            &format!("git worktree list failed: {}", error.message),
16253        ),
16254    }
16255    (snapshot, false)
16256}
16257
16258fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16259    let mut remaining = output;
16260    while !remaining.is_empty() {
16261        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16262            snapshot.truncated = true;
16263            break;
16264        }
16265        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16266            snapshot.truncated = true;
16267            break;
16268        };
16269        let record = &remaining[..record_end];
16270        remaining = &remaining[record_end + 1..];
16271        let has_previous_path = record
16272            .get(..2)
16273            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16274        let previous_path_bytes = if has_previous_path {
16275            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16276                snapshot.truncated = true;
16277                break;
16278            };
16279            let previous = &remaining[..previous_end];
16280            remaining = &remaining[previous_end + 1..];
16281            Some(previous)
16282        } else {
16283            None
16284        };
16285        if record.len() < 4
16286            || record[2] != b' '
16287            || !is_git_status_code(record[0])
16288            || !is_git_status_code(record[1])
16289        {
16290            snapshot.truncated = true;
16291            continue;
16292        }
16293        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16294            snapshot.truncated = true;
16295            continue;
16296        };
16297        let previous_path = match previous_path_bytes {
16298            Some(previous) => {
16299                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16300                    snapshot.truncated = true;
16301                    continue;
16302                };
16303                Some(previous)
16304            }
16305            None => None,
16306        };
16307        snapshot.status.push(GitStatusEntry {
16308            index_status: (record[0] as char).to_string(),
16309            worktree_status: (record[1] as char).to_string(),
16310            path,
16311            previous_path,
16312        });
16313    }
16314}
16315
16316fn is_git_status_code(byte: u8) -> bool {
16317    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16318}
16319
16320fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16321    for line in String::from_utf8_lossy(output).lines() {
16322        let Some((id, summary)) = line.split_once('\u{1f}') else {
16323            continue;
16324        };
16325        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16326            snapshot.truncated = true;
16327            break;
16328        }
16329        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16330        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16331        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16332    }
16333}
16334
16335fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16336    if value.len() <= max_bytes {
16337        return value.to_owned();
16338    }
16339    *truncated = true;
16340    let mut boundary = max_bytes;
16341    while boundary > 0 && !value.is_char_boundary(boundary) {
16342        boundary -= 1;
16343    }
16344    value[..boundary].to_owned()
16345}
16346
16347fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16348    if message.is_empty() {
16349        return;
16350    }
16351    let normalized = message
16352        .chars()
16353        .map(|character| if character.is_control() { ' ' } else { character })
16354        .collect::<String>();
16355    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16356    if !diagnostic.is_empty() {
16357        diagnostic.push_str("; ");
16358    }
16359    diagnostic.push_str(normalized.trim());
16360    snapshot.diagnostic = Some(truncate_git_field(
16361        &diagnostic,
16362        GIT_DIAGNOSTIC_MAX_BYTES,
16363        &mut snapshot.truncated,
16364    ));
16365}
16366
16367async fn run_git_bounded(
16368    root: &str,
16369    arguments: &[&str],
16370    output_limit: usize,
16371) -> io::Result<GitCommandOutput> {
16372    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16373}
16374
16375async fn read_git_history_bounded(
16376    root: &str,
16377    path: Option<&RepositoryPath>,
16378    before: Option<&GitObjectId>,
16379    limit: u16,
16380) -> Result<GitHistoryPage, NodeFailure> {
16381    let path = path
16382        .map(|path| {
16383            path.as_utf8().ok_or_else(|| {
16384                failure(
16385                    NodeFailureCode::InvalidRepositoryPath,
16386                    "git history path must use UTF-8",
16387                )
16388            })
16389        })
16390        .transpose()?
16391        .unwrap_or(".");
16392    let count = (usize::from(limit) + 1).to_string();
16393    let start = before
16394        .map(|revision| format!("{}^", revision.as_str()))
16395        .unwrap_or_else(|| "HEAD".to_owned());
16396    let arguments = [
16397        "--no-pager",
16398        "log",
16399        "--no-decorate",
16400        "--date=iso-strict",
16401        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16402        "-n",
16403        count.as_str(),
16404        start.as_str(),
16405        "--",
16406        path,
16407    ];
16408    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16409        .await
16410        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16411    if output.timed_out {
16412        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16413    }
16414    if !output.success {
16415        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16416    }
16417    let mut commits = parse_git_history(&output.stdout);
16418    let has_more = commits.len() > usize::from(limit) || output.truncated;
16419    commits.truncate(usize::from(limit));
16420    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16421    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16422}
16423
16424fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16425    String::from_utf8_lossy(output)
16426        .split('\u{1e}')
16427        .filter_map(|record| {
16428            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16429            if fields.len() != 9 {
16430                return None;
16431            }
16432            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16433            let parents = if fields[1].is_empty() {
16434                Vec::new()
16435            } else {
16436                fields[1]
16437                    .split(' ')
16438                    .map(|value| GitObjectId::new(value.to_owned()))
16439                    .collect::<Result<Vec<_>, _>>()
16440                    .ok()?
16441            };
16442            Some(GitCommitDetails {
16443                id,
16444                parents,
16445                subject: bounded_git_text(fields[2], 512),
16446                author_name: bounded_git_text(fields[3], 256),
16447                author_email: bounded_git_text(fields[4], 320),
16448                authored_at: bounded_git_text(fields[5], 64),
16449                committer_name: bounded_git_text(fields[6], 256),
16450                committer_email: bounded_git_text(fields[7], 320),
16451                committed_at: bounded_git_text(fields[8], 64),
16452                signature_status: GitSignatureStatus::NoSignature,
16453                signer: None,
16454            })
16455        })
16456        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16457        .collect()
16458}
16459
16460fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16461    let normalized = value
16462        .chars()
16463        .map(|character| if character.is_control() { ' ' } else { character })
16464        .collect::<String>();
16465    if normalized.len() <= max_bytes {
16466        return normalized;
16467    }
16468    let mut boundary = max_bytes;
16469    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16470        boundary -= 1;
16471    }
16472    normalized[..boundary].to_owned()
16473}
16474
16475async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16476    let path = request.path.as_ref().map(|path| {
16477        path.as_utf8()
16478            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16479    }).transpose()?;
16480    let mut arguments = match &request.mode {
16481        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16482        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16483        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16484    };
16485    if let GitDiffMode::Commit { revision } = &request.mode {
16486        arguments.push(revision.as_str());
16487    }
16488    arguments.push("--");
16489    if let Some(path) = path {
16490        arguments.push(path);
16491    }
16492    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16493        .await
16494        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16495    if output.timed_out {
16496        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16497    }
16498    if !output.success {
16499        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16500    }
16501    Ok(GitDiff {
16502        mode: request.mode,
16503        path: request.path,
16504        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16505        truncated: output.truncated,
16506    })
16507}
16508
16509async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16510    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16511    ResponseEnvelope { request_id: envelope.request_id, result }
16512}
16513
16514async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16515    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16516        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16517    }
16518    let read_only = matches!(
16519        &request,
16520        NodeRequest::Snapshot
16521            | NodeRequest::Resync { .. }
16522            | NodeRequest::BrowseHostDirectories { .. }
16523            | NodeRequest::InspectWorkspace { .. }
16524            | NodeRequest::ReadWorkspaceFile { .. }
16525            | NodeRequest::ReadGitHistory { .. }
16526            | NodeRequest::ReadGitDiff { .. }
16527            | NodeRequest::CatalogNativeSessions { .. }
16528            | NodeRequest::PageNativeSessions { .. }
16529            | NodeRequest::PreviewNativeSession { .. }
16530            | NodeRequest::PreviewSessionRecord { .. }
16531    );
16532    let _mutation_guard = if read_only {
16533        None
16534    } else {
16535        let guard = shared.mutation_gate.lock().await;
16536        if shared.shutdown.load(Ordering::Acquire) {
16537            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16538        }
16539        Some(guard)
16540    };
16541    match request {
16542        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16543            event_sequence: shared.current_sequence(),
16544            controller: shared.controller_state(),
16545            snapshot: shared.snapshot(),
16546        }),
16547        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16548        NodeRequest::ArmHarnessMcpReservation {
16549            reservation_id,
16550            activation_digest,
16551            spawn_spec,
16552            launch,
16553            expires_at_unix_ms,
16554        } => {
16555            shared.require_controller(connection_id, role)?;
16556            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16557                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16558            })?;
16559            let expires_at_unix_ms = registry
16560                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16561                .await
16562                .map_err(harness_mcp_failure)?;
16563            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16564        }
16565        NodeRequest::SpawnSpecWithHarnessMcp {
16566            reservation_id,
16567            activation_digest,
16568            spec,
16569            deadline_unix_ms,
16570        } => {
16571            shared.require_controller(connection_id, role)?;
16572            let receipt = shared
16573                .spawn_from_spec_with_harness_mcp(
16574                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16575                )
16576                .await?;
16577            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16578        }
16579        NodeRequest::ActivateHarnessMcpReservation {
16580            reservation_id,
16581            activation_digest,
16582            record_id,
16583            session,
16584        } => {
16585            shared.require_controller(connection_id, role)?;
16586            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16587                current.instance_id == session.session.instance_id
16588                    && current.generation == session.session.generation
16589            }).and_then(|current| current.process_id).ok_or_else(|| {
16590                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16591            })?;
16592            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16593                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16594            })?.activate(
16595                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16596            ).map_err(harness_mcp_failure)?;
16597            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16598        }
16599        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16600            shared.require_controller(connection_id, role)?;
16601            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16602                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16603            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16604            if let Some(instance_id) = instance_id {
16605                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16606            }
16607            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16608        }
16609        NodeRequest::PutHarnessMcpReplyChunk {
16610            reservation_id, activation_digest, record_id, session, call_id,
16611            offset, final_chunk, chunk_hex,
16612        } => {
16613            shared.require_controller(connection_id, role)?;
16614            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16615                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16616            })?.put_reply_chunk(
16617                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16618                offset, final_chunk, &chunk_hex,
16619            ).map_err(harness_mcp_failure)?;
16620            Ok(NodeResponse::ReplyChunkAccepted {
16621                reservation_id, activation_digest, record_id, session, call_id,
16622                next_offset, completed,
16623            })
16624        }
16625        NodeRequest::RejectHarnessMcpCall {
16626            reservation_id, activation_digest, record_id, session, call_id, reason,
16627        } => {
16628            shared.require_controller(connection_id, role)?;
16629            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16630                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16631            })?.reject_call(
16632                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16633            ).map_err(harness_mcp_failure)?;
16634            Ok(NodeResponse::CallRejected {
16635                reservation_id, activation_digest, record_id, session, call_id,
16636            })
16637        }
16638        NodeRequest::BeginDeliveryStage { manifest } => {
16639            shared.require_controller(connection_id, role)?;
16640            shared.begin_delivery_stage(manifest)
16641        }
16642        NodeRequest::PutDeliveryBlobChunk {
16643            stage_id,
16644            blob_digest,
16645            offset,
16646            chunk_hex,
16647        } => {
16648            shared.require_controller(connection_id, role)?;
16649            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16650        }
16651        NodeRequest::CommitDeliveryStage { stage_id } => {
16652            shared.require_controller(connection_id, role)?;
16653            let receipt = shared.commit_delivery_stage(stage_id)?;
16654            Ok(NodeResponse::DeliveryCommitted { receipt })
16655        }
16656        NodeRequest::AbortDeliveryStage { stage_id } => {
16657            shared.require_controller(connection_id, role)?;
16658            shared.abort_delivery_stage(&stage_id)?;
16659            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16660        }
16661        NodeRequest::BrowseHostDirectories { directory, after } => {
16662            let listing = shared.browse_host_directories(directory, after).await?;
16663            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16664        }
16665        NodeRequest::InspectWorkspace { workspace_id } => {
16666            let inspection = shared.inspect_workspace(workspace_id).await?;
16667            Ok(NodeResponse::WorkspaceInspected { inspection })
16668        }
16669        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16670            let file = shared.read_workspace_file(workspace_id, path).await?;
16671            Ok(NodeResponse::WorkspaceFileRead { file })
16672        }
16673        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16674            shared.require_controller(connection_id, role)?;
16675            let file = shared
16676                .write_workspace_file(workspace_id, path, expected_revision, text)
16677                .await?;
16678            Ok(NodeResponse::WorkspaceFileWritten { file })
16679        }
16680        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16681            shared.require_controller(connection_id, role)?;
16682            let file = shared.create_workspace_file(workspace_id, path).await?;
16683            Ok(NodeResponse::WorkspaceFileCreated { file })
16684        }
16685        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16686            shared.require_controller(connection_id, role)?;
16687            let entry = shared
16688                .create_workspace_directory(workspace_id.clone(), path)
16689                .await?;
16690            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16691        }
16692        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16693            if role != ClientRole::Operator {
16694                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16695            }
16696            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16697                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16698            }
16699            let page = shared
16700                .read_git_history(workspace_id.clone(), path, before, limit)
16701                .await?;
16702            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16703        }
16704        NodeRequest::ReadGitDiff { workspace_id, request } => {
16705            if role != ClientRole::Operator {
16706                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16707            }
16708            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16709            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16710        }
16711        NodeRequest::CatalogNativeSessions { route, limit } => {
16712            if role != ClientRole::Operator {
16713                return Err(failure(
16714                    NodeFailureCode::ObserverReadOnly,
16715                    "native session catalog requires an operator connection",
16716                ));
16717            }
16718            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16719                return Err(failure(
16720                    NodeFailureCode::InvalidRequest,
16721                    "native session catalog limit is invalid",
16722                ));
16723            }
16724            let (entries, summary) = shared
16725                .catalog_native_sessions(route.clone(), limit)
16726                .await?;
16727            Ok(NodeResponse::NativeSessionsCataloged {
16728                route,
16729                entries,
16730                summary: Some(summary),
16731            })
16732        }
16733        NodeRequest::PageNativeSessions {
16734            route,
16735            window,
16736            catalog_revision,
16737            recent_cutoff_unix_ms,
16738            after_selection_id,
16739            limit,
16740        } => {
16741            if role != ClientRole::Operator {
16742                return Err(failure(
16743                    NodeFailureCode::ObserverReadOnly,
16744                    "native session catalog paging requires an operator connection",
16745                ));
16746            }
16747            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16748                || after_selection_id
16749                    .as_deref()
16750                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16751            {
16752                return Err(failure(
16753                    NodeFailureCode::InvalidRequest,
16754                    "native session catalog page request is invalid",
16755                ));
16756            }
16757            let page = shared
16758                .page_native_sessions(
16759                    route.clone(),
16760                    window,
16761                    catalog_revision,
16762                    recent_cutoff_unix_ms,
16763                    after_selection_id,
16764                    limit,
16765                )
16766                .await?;
16767            Ok(NodeResponse::NativeSessionsPaged {
16768                route,
16769                page,
16770            })
16771        }
16772        NodeRequest::PreviewNativeSession {
16773            selection,
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                || selection.validate().is_err()
16785            {
16786                return Err(failure(
16787                    NodeFailureCode::InvalidRequest,
16788                    "native session preview request is invalid",
16789                ));
16790            }
16791            let preview = shared
16792                .preview_native_session(selection.clone(), message_limit)
16793                .await?;
16794            Ok(NodeResponse::NativeSessionPreviewed {
16795                selection,
16796                preview,
16797            })
16798        }
16799        NodeRequest::IndexNativeSession {
16800            selection,
16801            display_name,
16802        } => {
16803            shared.require_controller(connection_id, role)?;
16804            let record = shared
16805                .index_native_session(selection.clone(), display_name)
16806                .await?;
16807            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16808        }
16809        NodeRequest::PreviewSessionRecord {
16810            record_id,
16811            message_limit,
16812        } => {
16813            if role != ClientRole::Operator {
16814                return Err(failure(
16815                    NodeFailureCode::ObserverReadOnly,
16816                    "native session preview requires an operator connection",
16817                ));
16818            }
16819            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16820                .contains(&message_limit)
16821            {
16822                return Err(failure(
16823                    NodeFailureCode::InvalidRequest,
16824                    "native session preview request is invalid",
16825                ));
16826            }
16827            let record = shared.record(&record_id)?;
16828            let working_directory = shared.workspace_root(&record.workspace_id)?;
16829            if !platform::roots_equal(
16830                &working_directory,
16831                windows_path_text(&record.canonical_root),
16832            ) {
16833                return Err(failure(
16834                    NodeFailureCode::SessionRecordConflict,
16835                    "managed session workspace root changed; refusing to preview another directory",
16836                ));
16837            }
16838            let identity = record.provider_session.clone().ok_or_else(|| {
16839                failure(
16840                    NodeFailureCode::BackendOperationFailed,
16841                    "native session preview is unavailable for this session record",
16842                )
16843            })?;
16844            if identity.key != ProviderSessionKey::SessionId {
16845                return Err(failure(
16846                    NodeFailureCode::BackendOperationFailed,
16847                    "native session preview is unavailable for this session identity",
16848                ));
16849            }
16850            let preview = shared
16851                .preview_native_session_by_session_id(
16852                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16853                )
16854                .await?;
16855            if preview.session_id != identity.id {
16856                return Err(failure(
16857                    NodeFailureCode::SessionRecordConflict,
16858                    "native session preview identity changed while loading",
16859                ));
16860            }
16861            shared.revalidate_session_record_preview(&record, &identity)?;
16862            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16863            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16864                record_id: record_id.clone(),
16865                summary: SessionHistorySummaryV1::from(&preview),
16866            });
16867            Ok(NodeResponse::SessionRecordPreviewed {
16868                record_id,
16869                preview,
16870            })
16871        }
16872        NodeRequest::AcquireController { lease_ms } => {
16873            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16874            Ok(NodeResponse::Controller { controller: Some(controller) })
16875        }
16876        NodeRequest::ReleaseController => {
16877            shared.release_controller(connection_id);
16878            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16879        }
16880        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16881            shared.require_controller(connection_id, role)?;
16882            let root = require_windows_path(root)?;
16883            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16884            let workspace = shared.register_workspace(workspace_id, root).await?;
16885            Ok(NodeResponse::WorkspaceRegistered { workspace })
16886        }
16887        NodeRequest::CreateStandaloneWorkspace {
16888            workspace_id,
16889            root,
16890            initial_branch,
16891        } => {
16892            shared.require_controller(connection_id, role)?;
16893            let root = require_windows_path(root)?;
16894            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16895            let workspace = shared
16896                .create_standalone_workspace(workspace_id, root, initial_branch)
16897                .await?;
16898            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16899        }
16900        NodeRequest::UnregisterWorkspace { workspace_id } => {
16901            shared.require_controller(connection_id, role)?;
16902            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16903            shared.unregister_workspace(&workspace_id)?;
16904            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16905        }
16906        NodeRequest::CreateWorktree {
16907            source_workspace_id,
16908            workspace_id,
16909            target_root,
16910            branch,
16911            base,
16912        } => {
16913            shared.require_controller(connection_id, role)?;
16914            let target_root = require_windows_path(target_root)?;
16915            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16916            let (worktree, workspace) = shared
16917                .create_worktree(
16918                    source_workspace_id,
16919                    workspace_id,
16920                    target_root,
16921                    branch,
16922                    base,
16923                )
16924                .await?;
16925            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16926        }
16927        NodeRequest::RemoveWorktree {
16928            source_workspace_id,
16929            target_root,
16930        } => {
16931            shared.require_controller(connection_id, role)?;
16932            let native_target_root = require_windows_path(target_root.clone())?;
16933            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16934            let workspace_id = shared
16935                .remove_worktree(source_workspace_id, native_target_root)
16936                .await?;
16937            Ok(NodeResponse::WorktreeRemoved {
16938                target_root,
16939                workspace_id,
16940            })
16941        }
16942        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16943            shared.require_controller(connection_id, role)?;
16944            if !terminal_size.is_valid() {
16945                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16946            }
16947            if let Some(prompt) = initial_prompt.as_deref() {
16948                validate_node_text("spawn initial prompt", prompt)?;
16949            }
16950            match shared
16951                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16952                .await
16953            {
16954                Ok(session) => {
16955                    shared.mark_spawn_provisional(session.clone());
16956                    Ok(NodeResponse::SpawnAccepted { session })
16957                }
16958                Err(err) => {
16959                    tracing::warn!(
16960                        %provider,
16961                        %workspace_id,
16962                        ?mode,
16963                        code = ?err.code,
16964                        message = %err.message,
16965                        "session spawn refused"
16966                    );
16967                    Err(err)
16968                }
16969            }
16970        }
16971        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16972            shared.require_controller(connection_id, role)?;
16973            if !terminal_size.is_valid() {
16974                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16975            }
16976            if let Some(prompt) = initial_prompt.as_deref() {
16977                validate_node_text("spawn initial prompt", prompt)?;
16978            }
16979            let plan = backend_paths::plan_launch_options(
16980                provider.as_str(),
16981                mode,
16982                options.model.is_some(),
16983                options.effort.is_some(),
16984            )
16985            .map_err(|r| {
16986                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16987                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16988            })?;
16989            let selection = spawn_selection(&options)?;
16990            let pty_selection = match plan {
16991                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16992                _ => None,
16993            };
16994            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16995                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16996                    SPAWN_SESSION_OPTIONS
16997                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16998                        .await
16999                }).await
17000            }).await;
17001            match spawned {
17002                Ok(session) => {
17003                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17004                        shared
17005                            .pending_acp_options
17006                            .lock()
17007                            .unwrap_or_else(|p| p.into_inner())
17008                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17009                    }
17010                    shared.mark_spawn_provisional(session.clone());
17011                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17012                    Ok(NodeResponse::SpawnAccepted { session })
17013                }
17014                Err(err) => {
17015                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17016                    Err(err)
17017                }
17018            }
17019        }
17020        NodeRequest::SpawnSpec { spec } => {
17021            shared.require_controller(connection_id, role)?;
17022            let receipt = shared.spawn_from_spec(spec).await?;
17023            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17024        }
17025        NodeRequest::SpawnManagedWorktree { request } => {
17026            shared.require_controller(connection_id, role)?;
17027            let receipt = shared.spawn_managed_worktree(request).await?;
17028            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17029        }
17030        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17031            shared.require_controller(connection_id, role)?;
17032            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17033            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17034        }
17035        NodeRequest::CleanupManagedWorktree { lease_id } => {
17036            shared.require_controller(connection_id, role)?;
17037            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17038            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17039        }
17040        NodeRequest::DiscoverHistory { session, limit } => {
17041            controlled_session(shared, connection_id, role, &session)?;
17042            let candidates = shared.discover_history(&session, limit).await?;
17043            Ok(NodeResponse::HistoryDiscovered {
17044                session,
17045                candidates,
17046            })
17047        }
17048        NodeRequest::LoadHistory {
17049            session,
17050            candidate_id,
17051        } => {
17052            controlled_session(shared, connection_id, role, &session)?;
17053            let loaded = shared
17054                .load_history(&session, candidate_id)
17055                .await?;
17056            Ok(NodeResponse::HistoryLoaded {
17057                session,
17058                session_id: loaded.session_id,
17059                message_count: loaded.message_count,
17060                completed_turn_count: loaded.completed_turn_count,
17061            })
17062        }
17063        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17064            shared.require_controller(connection_id, role)?;
17065            let context = shared
17066                .export_context_pack_for_session_record(&record_id, &session, false)
17067                .await?;
17068            Ok(NodeResponse::ContextPackForSessionRecordExported {
17069                record_id,
17070                session,
17071                context,
17072            })
17073        }
17074        NodeRequest::ExportContextPack { session } => {
17075            controlled_session(shared, connection_id, role, &session)?;
17076            let context = shared.export_context_pack(&session).await?;
17077            Ok(NodeResponse::ContextPackExported { context })
17078        }
17079        NodeRequest::ForgetContextPack { context_id } => {
17080            shared.require_controller(connection_id, role)?;
17081            shared.forget_context_pack(&context_id)?;
17082            Ok(NodeResponse::ContextPackForgotten { context_id })
17083        }
17084        NodeRequest::ResolveDurableContextPack { context_id } => {
17085            // Read-only, idempotent, no controller lease required: this lets
17086            // a caller preflight "is this durably exported pack still here"
17087            // without holding the Node's mutation controller.
17088            let context = shared
17089                .context_catalog
17090                .read()
17091                .unwrap_or_else(|poisoned| poisoned.into_inner())
17092                .get(&context_id)
17093                .map(|pack| pack.receipt().clone())
17094                .ok_or_else(|| {
17095                    failure(
17096                        NodeFailureCode::UnknownContextPack,
17097                        "context pack is not present",
17098                    )
17099                })?;
17100            Ok(NodeResponse::DurableContextPackResolved { context })
17101        }
17102        NodeRequest::ReadContextPack { digest } => {
17103            // Read-only fetch of the pack's own bytes, keyed by content digest
17104            // rather than the caller's `SpawnContextId`: same no-controller-lease
17105            // rationale as `ResolveDurableContextPack` above, one step further
17106            // down the same catalog.
17107            let (id, byte_len, bytes) = shared
17108                .context_catalog
17109                .read()
17110                .unwrap_or_else(|poisoned| poisoned.into_inner())
17111                .get_by_digest(&digest)
17112                .map(|pack| {
17113                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17114                })
17115                .ok_or_else(|| {
17116                    failure(
17117                        NodeFailureCode::UnknownContextPack,
17118                        &format!("context pack {digest} not on this node"),
17119                    )
17120                })?;
17121            tracing::debug!(
17122                digest = digest.as_str(),
17123                byte_len,
17124                "read context pack bytes",
17125            );
17126            Ok(NodeResponse::ContextPackBytesRead {
17127                pack: ContextPackBytesRead {
17128                    digest,
17129                    id,
17130                    byte_len,
17131                    bytes,
17132                },
17133            })
17134        }
17135        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17136            let provider = controlled_session(shared, connection_id, role, &session)?;
17137            if let Some(prompt) = initial_prompt.as_deref() {
17138                validate_node_text("resume initial prompt", prompt)?;
17139            }
17140            let runtime_requirement = if initial_prompt.is_some() {
17141                ProviderRuntimeRequirement::ResumeWithPrompt
17142            } else {
17143                ProviderRuntimeRequirement::Resume
17144            };
17145            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17146            let runtime_policy = shared
17147                .admit_provider_runtime(&provider, runtime_requirement)
17148                .await?;
17149            let working_directory = shared.bound_workspace_root(&session)?;
17150            let request = ResumeLaunchRequest {
17151                working_directory,
17152                terminal_size,
17153                initial_prompt,
17154            };
17155            request
17156                .validate()
17157                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17158            let command = shared.prepare_command(ControlCommand::Resume {
17159                instance_id: session.session.instance_id,
17160                target: ResumeTarget::CurrentProvider,
17161                runtime_policy,
17162                request,
17163            });
17164            shared.arm_resume(&session, command.id, runtime_policy)?;
17165            let dispatch = shared.dispatch_envelope(command);
17166            if let Err(error) = dispatch {
17167                shared.clear_armed_resume(&session);
17168                return Err(error);
17169            }
17170            Ok(NodeResponse::Accepted)
17171        }
17172        NodeRequest::RenameSessionRecord {
17173            record_id,
17174            display_name,
17175        } => {
17176            shared.require_controller(connection_id, role)?;
17177            let record = shared.rename_session_record(&record_id, display_name)?;
17178            Ok(NodeResponse::SessionRecordUpdated { record })
17179        }
17180        NodeRequest::SetSessionTask {
17181            record_id,
17182            expected_revision,
17183            target,
17184        } => {
17185            shared.require_controller(connection_id, role)?;
17186            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17187            Ok(NodeResponse::SessionRecordUpdated { record })
17188        }
17189        NodeRequest::IndexProviderSession {
17190            workspace_id,
17191            provider,
17192            identity,
17193            display_name,
17194        } => {
17195            shared.require_controller(connection_id, role)?;
17196            let record = shared.index_provider_session(
17197                workspace_id,
17198                provider,
17199                identity,
17200                display_name,
17201            )?;
17202            Ok(NodeResponse::ProviderSessionIndexed { record })
17203        }
17204        NodeRequest::ResumeSessionRecord {
17205            record_id,
17206            terminal_size,
17207            initial_prompt,
17208        } => {
17209            shared.require_controller(connection_id, role)?;
17210            if let Some(prompt) = initial_prompt.as_deref() {
17211                validate_node_text("managed resume initial prompt", prompt)?;
17212            }
17213            let (record, session) = shared
17214                .resume_session_record(
17215                    &record_id,
17216                    terminal_size,
17217                    initial_prompt,
17218                )
17219                .await?;
17220            Ok(NodeResponse::SessionRecordResumed { record, session })
17221        }
17222        NodeRequest::ForgetSessionRecord { record_id } => {
17223            shared.require_controller(connection_id, role)?;
17224            shared.forget_session_record(&record_id).await?;
17225            Ok(NodeResponse::SessionRecordForgotten { record_id })
17226        }
17227        NodeRequest::Prompt { session, text } => {
17228            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17229            validate_node_text("prompt", &text)?;
17230            shared.require_session_runtime_policy(
17231                &session,
17232                ProviderRuntimeRequirement::SemanticPrompt,
17233            )?;
17234            let framing = prompt_framing(&agent_id);
17235            shared
17236                .dispatch_input_bounded(
17237                    &session,
17238                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17239                )
17240                .await?;
17241            Ok(NodeResponse::Accepted)
17242        }
17243        NodeRequest::Paste { session, text } => {
17244            controlled_session(shared, connection_id, role, &session)?;
17245            validate_node_text("paste", &text)?;
17246            shared.require_session_runtime_policy(
17247                &session,
17248                ProviderRuntimeRequirement::SemanticPrompt,
17249            )?;
17250            shared
17251                .dispatch_input_bounded(
17252                    &session,
17253                    InputAction::InsertDraft(PromptPayload {
17254                        text,
17255                        framing: PromptFraming::BracketedPaste,
17256                    }),
17257                )
17258                .await?;
17259            Ok(NodeResponse::Accepted)
17260        }
17261        NodeRequest::Input { session, text } => {
17262            controlled_session(shared, connection_id, role, &session)?;
17263            validate_node_text("terminal input", &text)?;
17264            shared
17265                .dispatch_input_bounded(
17266                    &session,
17267                    InputAction::TerminalText(TerminalText { text }),
17268                )
17269                .await?;
17270            Ok(NodeResponse::Accepted)
17271        }
17272        NodeRequest::TerminalBytes { session, bytes } => {
17273            controlled_session(shared, connection_id, role, &session)?;
17274            shared.sessionize_spawn(session.session.instance_id);
17275            let action = terminal_bytes_action(bytes)?;
17276            shared
17277                .dispatch_input_bounded(&session, action)
17278                .await?;
17279            Ok(NodeResponse::Accepted)
17280        }
17281        NodeRequest::TerminalControl { session, control } => {
17282            controlled_session(shared, connection_id, role, &session)?;
17283            shared
17284                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17285                .await?;
17286            Ok(NodeResponse::Accepted)
17287        }
17288        NodeRequest::Resize { session, size } => {
17289            controlled_session(shared, connection_id, role, &session)?;
17290            shared.sessionize_spawn(session.session.instance_id);
17291            if !size.is_valid() {
17292                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17293            }
17294            shared.dispatch_resize_bounded(&session, size).await?;
17295            Ok(NodeResponse::Accepted)
17296        }
17297        NodeRequest::Interrupt { session } => {
17298            controlled_session(shared, connection_id, role, &session)?;
17299            shared
17300                .dispatch_input_bounded(
17301                    &session,
17302                    InputAction::TerminalControl(TerminalControl::Interrupt),
17303                )
17304                .await?;
17305            Ok(NodeResponse::Accepted)
17306        }
17307        NodeRequest::Stop { session, force } => {
17308            controlled_session(shared, connection_id, role, &session)?;
17309            shared.sessionize_spawn(session.session.instance_id);
17310            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17311            shared.retire_session_grok_leader(session.session.instance_id);
17312            Ok(NodeResponse::Accepted)
17313        }
17314        NodeRequest::Remove { session } => {
17315            controlled_session(shared, connection_id, role, &session)?;
17316            shared.remove_session(&session).await?;
17317            Ok(NodeResponse::Accepted)
17318        }
17319        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17320            shared.require_controller(connection_id, role)?;
17321            let snapshot = shared.require_acp_session(&session)?;
17322            let interaction = snapshot
17323                .provider
17324                .interactions
17325                .iter()
17326                .find(|interaction| {
17327                    interaction_correlation(
17328                        session.session.instance_id,
17329                        session.session.generation,
17330                        interaction.id.0,
17331                    ) == correlation_id
17332                })
17333                .ok_or_else(|| failure(
17334                    NodeFailureCode::InvalidRequest,
17335                    "unknown interaction correlation",
17336                ))?;
17337            if interaction.status != ProviderInteractionStatus::Pending {
17338                return Err(failure(
17339                    NodeFailureCode::BackendOperationFailed,
17340                    "interaction has already been resolved",
17341                ));
17342            }
17343            response
17344                .validate_for(interaction.interaction_kind)
17345                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17346            let interaction_id = interaction.id;
17347            let response_kind = response.kind();
17348            let response_outcome = response.outcome();
17349            shared.dispatch(ControlCommand::ResolveInteraction {
17350                instance_id: session.session.instance_id,
17351                generation: session.session.generation,
17352                interaction_id,
17353                response,
17354            })?;
17355            // The one line that lets an operator compare what THIS node
17356            // recorded against what the provider itself reports doing --
17357            // measured live on a kimi run where our side resolved `true`
17358            // while the agent reported "rejected" and neither layer had a
17359            // line to line up against the other.
17360            tracing::info!(
17361                node_id = %shared.node_id,
17362                session = ?session.session,
17363                correlation_id = %correlation_id,
17364                interaction_id = interaction_id.0,
17365                response_kind = ?response_kind,
17366                outcome = ?response_outcome,
17367                "interaction resolved",
17368            );
17369            Ok(NodeResponse::Accepted)
17370        }
17371        NodeRequest::SetSessionMode { session, mode_id } => {
17372            shared.require_controller(connection_id, role)?;
17373            shared.require_acp_session(&session)?;
17374            shared.dispatch(ControlCommand::SetSessionMode {
17375                instance_id: session.session.instance_id,
17376                mode_id,
17377            })?;
17378            Ok(NodeResponse::Accepted)
17379        }
17380        NodeRequest::SetSessionConfigOption {
17381            session,
17382            option_id,
17383            value_json,
17384        } => {
17385            shared.require_controller(connection_id, role)?;
17386            shared.require_acp_session(&session)?;
17387            shared.dispatch(ControlCommand::SetSessionConfigOption {
17388                instance_id: session.session.instance_id,
17389                option_id,
17390                value_json,
17391            })?;
17392            Ok(NodeResponse::Accepted)
17393        }
17394        NodeRequest::SetSessionModel { session, model_id } => {
17395            shared.require_controller(connection_id, role)?;
17396            shared.require_acp_session(&session)?;
17397            shared.dispatch(ControlCommand::SetSessionModel {
17398                instance_id: session.session.instance_id,
17399                model_id,
17400            })?;
17401            Ok(NodeResponse::Accepted)
17402        }
17403        NodeRequest::Shutdown => {
17404            shared.require_controller(connection_id, role)?;
17405            shared.begin_shutdown_locked().await?;
17406            Ok(NodeResponse::ShuttingDown)
17407        }
17408    }
17409}
17410
17411fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17412    shared.require_controller(connection_id, role)?;
17413    shared.validate_address(session)
17414}
17415
17416/// Selects the sole history candidate matching `provider_session_id` when
17417/// the managed record carries a verified identity, or the sole candidate in
17418/// the discovered set at all when it does not (see
17419/// `export_context_pack_for_session_record_inner`'s identity match). Either
17420/// way, more than one match refuses instead of guessing which one is exact.
17421fn select_history_candidate(
17422    candidates: &[HistoryCandidateSummary],
17423    provider_session_id: Option<&str>,
17424) -> Result<String, NodeFailure> {
17425    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17426        Some(id) => candidate.session_id_hint == id,
17427        None => true,
17428    });
17429    let candidate = matches.next().ok_or_else(|| {
17430        failure(
17431            NodeFailureCode::SessionRecordConflict,
17432            if provider_session_id.is_some() {
17433                "no history candidate matches the managed provider session"
17434            } else {
17435                "no history candidate exists for the managed session"
17436            },
17437        )
17438    })?;
17439    if matches.next().is_some() {
17440        return Err(failure(
17441            NodeFailureCode::SessionRecordConflict,
17442            if provider_session_id.is_some() {
17443                "history identity is ambiguous for the managed provider session"
17444            } else {
17445                "history is ambiguous without a verified provider session identity"
17446            },
17447        ));
17448    }
17449    Ok(candidate.id.clone())
17450}
17451
17452fn resolved_spawn_profile_summary(
17453    profile: &SpawnProfileDefaults,
17454    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17455) -> Option<SpawnProfileSummary> {
17456    let environment_profile = match profile.environment_profile_id.as_ref() {
17457        None => None,
17458        Some(profile_id) => {
17459            let binding = environment_profiles.get(profile_id)?;
17460            if binding.provider != profile.provider
17461                || binding.native_profile_id(profile.mode).is_none()
17462            {
17463                return None;
17464            }
17465            Some(ResolvedEnvironmentProfileReceipt {
17466                profile_id: binding.id.clone(),
17467                profile_revision: binding.revision.clone(),
17468                network_allowlist: None,
17469                browser_profile_id: None,
17470            })
17471        }
17472    };
17473    Some(SpawnProfileSummary {
17474        id: profile.profile_id.clone(),
17475        revision: profile.revision.clone(),
17476        environment_profile,
17477    })
17478}
17479
17480/// True if `current` is an acceptable version of `expected` at export
17481/// revalidation/commit time.
17482///
17483/// When `allow_clean_detachment` is `false` (the on-demand,
17484/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17485/// be byte-identical to `expected` and still actively bound to `session` —
17486/// the original, unchanged invariant.
17487///
17488/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17489/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17490/// export reacts to produces on its own: `state` Live -> Dormant,
17491/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17492/// advancing. Every other field — including `provider_session`, `context`,
17493/// `bundle`, `task_binding` — must still match exactly. A record that changed
17494/// in any other way (rebind, a different active session, an identity
17495/// conflict, capacity eviction) is rejected either way; this widens nothing
17496/// about which sessions are eligible, only how the record's own expected
17497/// detachment is tolerated mid-export.
17498fn session_record_export_target_matches(
17499    expected: &ManagedSessionRecord,
17500    current: &ManagedSessionRecord,
17501    session: &SessionAddress,
17502    allow_clean_detachment: bool,
17503) -> bool {
17504    if current == expected && current.active_session.as_ref() == Some(session) {
17505        return current.state == ManagedSessionState::Live;
17506    }
17507    allow_clean_detachment
17508        && current.state == ManagedSessionState::Dormant
17509        && current.active_session.is_none()
17510        && current.record_id == expected.record_id
17511        && current.display_name == expected.display_name
17512        && current.provider == expected.provider
17513        && current.mode == expected.mode
17514        && current.workspace_id == expected.workspace_id
17515        && current.canonical_root == expected.canonical_root
17516        && current.provider_session == expected.provider_session
17517        && current.environment_profile == expected.environment_profile
17518        && current.bundle == expected.bundle
17519        && current.context_id == expected.context_id
17520        && current.context == expected.context
17521        && current.exported_context == expected.exported_context
17522        && current.task_binding == expected.task_binding
17523        && current.created_at_unix_ms == expected.created_at_unix_ms
17524        && current.last_error == expected.last_error
17525}
17526
17527fn session_record_context_export_binding_is_exact(
17528    expected: &ManagedSessionRecord,
17529    current: &ManagedSessionRecord,
17530    identity: Option<&ProviderSessionIdentity>,
17531    session: &SessionAddress,
17532    binding: &SessionBinding,
17533    provider: &AgentId,
17534    current_root: &str,
17535    allow_clean_detachment: bool,
17536) -> bool {
17537    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17538        && current.provider_session.as_ref() == identity
17539        && current.workspace_id == session.workspace_id
17540        && provider == &current.provider
17541        && binding.workspace_id == session.workspace_id
17542        && binding.generation == session.session.generation
17543        && binding.record_id.as_ref() == Some(&current.record_id)
17544        && platform::roots_equal(
17545            current_root,
17546            windows_path_text(&current.canonical_root),
17547        )
17548}
17549
17550fn session_record_context_export_source_is_usable(
17551    record: &ManagedSessionRecord,
17552    status: &SessionStatus,
17553    allow_clean_detachment: bool,
17554) -> bool {
17555    (record.state == ManagedSessionState::Live
17556        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17557        && context_pack_source_status_is_usable(status)
17558}
17559
17560fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17561    PromptFraming::BracketedPaste
17562}
17563
17564fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17565    if text.len() > MAX_NODE_TEXT_BYTES {
17566        return Err(failure(
17567            NodeFailureCode::InvalidRequest,
17568            &format!(
17569                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17570                text.len(),
17571            ),
17572        ));
17573    }
17574    Ok(())
17575}
17576
17577fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17578    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17579        return Err(failure(
17580            NodeFailureCode::InvalidRequest,
17581            &format!(
17582                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17583                bytes.len(),
17584            ),
17585        ));
17586    }
17587    Ok(())
17588}
17589
17590fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17591    validate_terminal_bytes(&bytes)?;
17592    Ok(InputAction::TerminalBytes(bytes))
17593}
17594
17595fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17596    if root.is_empty()
17597        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17598        || root.chars().any(char::is_control)
17599    {
17600        return Err(failure(
17601            NodeFailureCode::InvalidWorkspaceRoot,
17602            &format!(
17603                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17604            ),
17605        ));
17606    }
17607    Ok(())
17608}
17609
17610fn canonical_standalone_workspace_candidate(
17611    workspace_id: &WorkspaceId,
17612    root: &str,
17613) -> Result<String, NodeServerError> {
17614    let path = Path::new(root);
17615    match std::fs::symlink_metadata(path) {
17616        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17617            .map(|workspace| workspace.canonical_root),
17618        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17619            if !path.is_absolute() {
17620                return Err(NodeServerError::InvalidWorkspaceRoot {
17621                    workspace_id: workspace_id.clone(),
17622                    path: root.to_owned(),
17623                    message: "path is not absolute".to_owned(),
17624                });
17625            }
17626            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17627                workspace_id: workspace_id.clone(),
17628                path: root.to_owned(),
17629                message: "path has no standalone directory name".to_owned(),
17630            })?;
17631            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17632                workspace_id: workspace_id.clone(),
17633                path: root.to_owned(),
17634                message: "path has no parent directory".to_owned(),
17635            })?;
17636            let canonical = std::fs::canonicalize(parent)
17637                .map(|parent| parent.join(name))
17638                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17639                    workspace_id: workspace_id.clone(),
17640                    path: root.to_owned(),
17641                    message: error.to_string(),
17642                })?;
17643            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17644                NodeServerError::InvalidWorkspaceRoot {
17645                    workspace_id: workspace_id.clone(),
17646                    path: path.to_string_lossy().into_owned(),
17647                    message: "canonical path is not valid Unicode".to_owned(),
17648                }
17649            })?;
17650            let canonical_root = platform::normalize_canonical_root(canonical_root);
17651            validate_workspace_root(workspace_id, &canonical_root)?;
17652            Ok(canonical_root)
17653        }
17654        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17655            workspace_id: workspace_id.clone(),
17656            path: root.to_owned(),
17657            message: error.to_string(),
17658        }),
17659    }
17660}
17661
17662fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17663    let Some(revision) = revision else {
17664        return Ok(());
17665    };
17666    if revision.is_empty()
17667        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17668        || revision.chars().any(char::is_control)
17669    {
17670        return Err(failure(
17671            NodeFailureCode::InvalidRequest,
17672            &format!(
17673                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17674            ),
17675        ));
17676    }
17677    Ok(())
17678}
17679
17680fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17681    let code = match error.kind {
17682        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17683        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17684        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17685        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17686        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17687        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17688        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17689    };
17690    failure(code, &error.message)
17691}
17692
17693fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17694    let code = match error.kind {
17695        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17696        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17697        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17698            NodeFailureCode::BackendOperationFailed
17699        }
17700        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17701            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17702        }
17703    };
17704    failure(code, error.message)
17705}
17706
17707fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17708    let (code, message) = match error.kind {
17709        GitWorktreeErrorKind::Invalid => (
17710            NodeFailureCode::InvalidRequest,
17711            "managed Git input is invalid",
17712        ),
17713        GitWorktreeErrorKind::NotRepository => (
17714            NodeFailureCode::NotGitRepository,
17715            "managed source is not a Git repository",
17716        ),
17717        GitWorktreeErrorKind::Conflict => (
17718            NodeFailureCode::WorktreeConflict,
17719            "managed Git identity conflicts with existing repository state",
17720        ),
17721        GitWorktreeErrorKind::Protected => (
17722            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17723            "managed worktree ownership validation failed",
17724        ),
17725        GitWorktreeErrorKind::Dirty => (
17726            NodeFailureCode::WorktreeDirty,
17727            "managed worktree contains uncommitted changes",
17728        ),
17729        GitWorktreeErrorKind::Locked => (
17730            NodeFailureCode::WorktreeLocked,
17731            "managed worktree is locked",
17732        ),
17733        GitWorktreeErrorKind::Failed => (
17734            NodeFailureCode::BackendOperationFailed,
17735            "managed Git operation failed",
17736        ),
17737    };
17738    NodeFailure { code, message: message.to_owned() }
17739}
17740
17741fn managed_spawn_receipt(
17742    resolved: &ResolvedSpawnSpec,
17743    incarnation_id: NodeIncarnationId,
17744    session: SessionAddress,
17745    lease: ManagedWorktreeLeaseSnapshot,
17746    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17747    bundle: Option<ResolvedBundleReceipt>,
17748    context: Option<ResolvedContextPackReceipt>,
17749) -> ManagedWorktreeSpawnReceipt {
17750    let mut managed = resolved.clone();
17751    managed.target.worktree_id = Some(lease.workspace_id.clone());
17752    ManagedWorktreeSpawnReceipt {
17753        spawn: managed.receipt_with_materialization(
17754            incarnation_id,
17755            session,
17756            environment_profile,
17757            bundle,
17758            context,
17759        ),
17760        lease,
17761    }
17762}
17763
17764fn validate_selected_worktree(
17765    source_root: &str,
17766    selected_root: &str,
17767    worktrees: &[NativeGitWorktreeSnapshot],
17768) -> Result<(), NodeFailure> {
17769    if !worktrees
17770        .iter()
17771        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17772    {
17773        return Err(failure(
17774            NodeFailureCode::WorktreeProtected,
17775            "source workspace is not an authoritative non-bare Git worktree root",
17776        ));
17777    }
17778    if !worktrees.iter().any(|worktree| {
17779        worktree_paths_equal(&worktree.path, selected_root)
17780            && !worktree.is_main
17781            && !worktree.is_bare
17782    }) {
17783        return Err(failure(
17784            NodeFailureCode::WorktreeProtected,
17785            "selected workspace is not an eligible Git worktree in the source repository",
17786        ));
17787    }
17788    Ok(())
17789}
17790
17791fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17792    let code = match error.kind() {
17793        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17794        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17795            NodeFailureCode::RepositoryPathUnsafe
17796        }
17797        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17798        WorkspaceFileReadErrorKind::NotRegularFile => {
17799            NodeFailureCode::RepositoryFileNotRegular
17800        }
17801        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17802            NodeFailureCode::RepositoryFileReadFailed
17803        }
17804        WorkspaceFileReadErrorKind::RevisionConflict => {
17805            NodeFailureCode::RepositoryFileRevisionConflict
17806        }
17807        WorkspaceFileReadErrorKind::AlreadyExists
17808        | WorkspaceFileReadErrorKind::ParentNotFound
17809        | WorkspaceFileReadErrorKind::ParentNotDirectory
17810        | WorkspaceFileReadErrorKind::Canceled => {
17811            NodeFailureCode::RepositoryFileReadFailed
17812        }
17813    };
17814    failure(code, "repository file read failed")
17815}
17816
17817fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17818    let code = match error.kind() {
17819        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17820        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17821            NodeFailureCode::RepositoryPathUnsafe
17822        }
17823        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17824        WorkspaceFileReadErrorKind::NotRegularFile => {
17825            NodeFailureCode::RepositoryFileNotRegular
17826        }
17827        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17828            NodeFailureCode::RepositoryFileWriteFailed
17829        }
17830        WorkspaceFileReadErrorKind::RevisionConflict => {
17831            NodeFailureCode::RepositoryFileRevisionConflict
17832        }
17833        WorkspaceFileReadErrorKind::AlreadyExists
17834        | WorkspaceFileReadErrorKind::ParentNotFound
17835        | WorkspaceFileReadErrorKind::ParentNotDirectory
17836        | WorkspaceFileReadErrorKind::Canceled => {
17837            NodeFailureCode::RepositoryFileWriteFailed
17838        }
17839    };
17840    failure(code, "repository file write failed")
17841}
17842
17843async fn settle_workspace_entry_create<T>(
17844    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17845    commit_state: Arc<AtomicU8>,
17846    deadline: Duration,
17847    timeout_message: &'static str,
17848    task_failure_message: &'static str,
17849) -> Result<T, NodeFailure> {
17850    let joined = match timeout(deadline, &mut task).await {
17851        Ok(joined) => joined,
17852        Err(_) => {
17853            if commit_state
17854                .compare_exchange(
17855                    WORKSPACE_ENTRY_CREATE_PENDING,
17856                    WORKSPACE_ENTRY_CREATE_CANCELED,
17857                    Ordering::AcqRel,
17858                    Ordering::Acquire,
17859                )
17860                .is_ok()
17861            {
17862                return Err(failure(
17863                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17864                    timeout_message,
17865                ));
17866            }
17867            debug_assert_eq!(
17868                commit_state.load(Ordering::Acquire),
17869                WORKSPACE_ENTRY_CREATE_COMMITTING,
17870            );
17871            task.await
17872        }
17873    };
17874    joined
17875        .map_err(|_| {
17876            failure(
17877                NodeFailureCode::RepositoryEntryCreateFailed,
17878                task_failure_message,
17879            )
17880        })?
17881        .map_err(workspace_entry_create_failure)
17882}
17883
17884fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17885    let code = match error.kind() {
17886        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17887        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17888            NodeFailureCode::RepositoryPathUnsafe
17889        }
17890        WorkspaceFileReadErrorKind::AlreadyExists => {
17891            NodeFailureCode::RepositoryEntryAlreadyExists
17892        }
17893        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17894            NodeFailureCode::RepositoryParentNotFound
17895        }
17896        WorkspaceFileReadErrorKind::ParentNotDirectory
17897        | WorkspaceFileReadErrorKind::NotRegularFile => {
17898            NodeFailureCode::RepositoryParentNotDirectory
17899        }
17900        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17901            NodeFailureCode::RepositoryEntryCreateFailed
17902        }
17903        WorkspaceFileReadErrorKind::RevisionConflict => {
17904            NodeFailureCode::RepositoryEntryCreateFailed
17905        }
17906        WorkspaceFileReadErrorKind::Canceled => {
17907            NodeFailureCode::RepositoryEntryCreateTimedOut
17908        }
17909    };
17910    failure(code, "repository entry create failed")
17911}
17912
17913fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17914    let value = digest(&SHA256, bytes)
17915        .as_ref()
17916        .iter()
17917        .map(|byte| format!("{byte:02x}"))
17918        .collect();
17919    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17920}
17921
17922fn managed_spawn_request_digest_v2(
17923    request: &ManagedWorktreeSpawnRequestV2,
17924) -> Result<String, NodeFailure> {
17925    let encoded = serde_json::to_vec(request).map_err(|_| {
17926        failure(
17927            NodeFailureCode::InvalidRequest,
17928            "managed worktree V2 request could not be canonicalized",
17929        )
17930    })?;
17931    let mut value = String::with_capacity(71);
17932    value.push_str("sha256:");
17933    for byte in digest(&SHA256, &encoded).as_ref() {
17934        use std::fmt::Write as _;
17935        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17936    }
17937    Ok(value)
17938}
17939
17940fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17941    let code = match error {
17942        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17943        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17944        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17945        NativeSessionPreviewError::UnsupportedProvider
17946        | NativeSessionPreviewError::PreviewUnavailable
17947        | NativeSessionPreviewError::SessionNotFound
17948        | NativeSessionPreviewError::AmbiguousSession => {
17949            NodeFailureCode::BackendOperationFailed
17950        }
17951    };
17952    failure(code, "native session preview failed")
17953}
17954
17955fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17956    let code = match error.kind() {
17957        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17958        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17959    };
17960    failure(code, "host directory browse failed")
17961}
17962
17963fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17964    let code = match error {
17965        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17966        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17967        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17968            NodeFailureCode::DeliveryStageConflict
17969        }
17970        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17971        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17972            NodeFailureCode::DeliveryChunkOutOfOrder
17973        }
17974        DeliveryStoreError::BlobDigestMismatch => {
17975            NodeFailureCode::DeliveryBlobDigestMismatch
17976        }
17977        DeliveryStoreError::BundleDigestMismatch => {
17978            NodeFailureCode::DeliveryBundleDigestMismatch
17979        }
17980        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17981        DeliveryStoreError::Unavailable
17982        | DeliveryStoreError::Corrupt
17983        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17984    };
17985    failure(code, "delivery operation failed")
17986}
17987
17988fn validate_workspace_root(
17989    workspace_id: &WorkspaceId,
17990    canonical_root: &str,
17991) -> Result<(), NodeServerError> {
17992    #[cfg(windows)]
17993    if canonical_root.starts_with(r"\\") {
17994        return Err(NodeServerError::InvalidWorkspaceRoot {
17995            workspace_id: workspace_id.clone(),
17996            path: canonical_root.to_owned(),
17997            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17998        });
17999    }
18000    if !platform::workspace_root_supported(canonical_root)
18001        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18002    {
18003        return Err(NodeServerError::InvalidWorkspaceRoot {
18004            workspace_id: workspace_id.clone(),
18005            path: canonical_root.to_owned(),
18006            message: "canonical path must be absolute, bounded, and free of control characters"
18007                .to_owned(),
18008        });
18009    }
18010    Ok(())
18011}
18012
18013fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18014    NodeFailure {
18015        code,
18016        message: node_failure_category(code).to_owned(),
18017    }
18018}
18019
18020fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18021    let code = match error {
18022        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18023        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18024        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18025        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18026        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18027        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18028        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18029        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18030        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18031    };
18032    failure(code, "harness MCP proxy operation failed")
18033}
18034
18035/// Numbers each harness-MCP overlay this process constructs, in
18036/// construction order, so two overlays built back to back in one node process
18037/// still get distinct trace files.
18038static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18039
18040/// Builds the optional trace environment entry of one harness-MCP overlay.
18041/// The caller's launch description names both the entry and the node-process
18042/// variable that opts in: when that variable holds a non-empty directory, the
18043/// entry names a trace file under it. Returns `None` when the launch has no
18044/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18045/// the directory; that is the operator's job.
18046fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18047    let trace = launch.trace.as_ref()?;
18048    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18049    if trace_dir.is_empty() {
18050        return None;
18051    }
18052    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18053    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18054    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18055}
18056
18057fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18058    let remaining = deadline.saturating_duration_since(Instant::now());
18059    if remaining.is_zero() {
18060        Err(failure(
18061            NodeFailureCode::SpawnDeadlineExceeded,
18062            "spawn deadline elapsed",
18063        ))
18064    } else {
18065        Ok(remaining)
18066    }
18067}
18068
18069fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18070    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18071    match deadline {
18072        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18073        None => Ok(bounded),
18074    }
18075}
18076
18077fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18078    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18079        failure(
18080            NodeFailureCode::SpawnDeadlineExceeded,
18081            "spawn dispatch exceeded the spawn deadline",
18082        )
18083    } else {
18084        error
18085    }
18086}
18087
18088fn spawn_runtime_capabilities(
18089    capabilities: &SpawnRequiredCapabilities,
18090) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18091    capabilities
18092        .iter()
18093        .map(|capability| match capability.as_str() {
18094            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18095            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18096            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18097            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18098                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18099            }
18100            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18101            _ => Err(failure(
18102                NodeFailureCode::UnsupportedSpawnCapability,
18103                "spawn required capability is unknown",
18104            )),
18105        })
18106        .collect()
18107}
18108
18109fn persistence_failure(_error: io::Error) -> NodeFailure {
18110    NodeFailure {
18111        code: NodeFailureCode::BackendOperationFailed,
18112        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18113    }
18114}
18115
18116fn node_failure_category(code: NodeFailureCode) -> &'static str {
18117    match code {
18118        NodeFailureCode::InvalidRequest => "invalid-request",
18119        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18120        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18121        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18122        NodeFailureCode::ReservationConflict => "reservation-conflict",
18123        NodeFailureCode::ReservationExpired => "reservation-expired",
18124        NodeFailureCode::BindingMismatch => "binding-mismatch",
18125        NodeFailureCode::NotActivated => "not-activated",
18126        NodeFailureCode::CallNotFound => "call-not-found",
18127        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18128        NodeFailureCode::ResponseTooLarge => "response-too-large",
18129        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18130        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18131        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18132        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18133        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18134        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18135        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18136        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18137        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18138        NodeFailureCode::Unauthorized => "unauthorized",
18139        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18140        NodeFailureCode::ControllerBusy => "controller-busy",
18141        NodeFailureCode::ControllerRequired => "controller-required",
18142        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18143        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18144        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18145        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18146        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18147        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18148        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18149        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18150        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18151        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18152        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18153        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18154        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18155        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18156        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18157        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18158        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18159        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18160        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18161        NodeFailureCode::GitReadFailed => "git-read-failed",
18162        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18163        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18164        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18165        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18166        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18167        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18168        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18169        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18170        NodeFailureCode::TurnInFlight => "turn-in-flight",
18171        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18172        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18173        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18174            "unsupported-network-allowlist-mapping"
18175        }
18176        NodeFailureCode::BrowserStationProbeUnavailable => {
18177            "browser-station-probe-unavailable"
18178        }
18179        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18180        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18181        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18182            "environment-profile-binding-mismatch"
18183        }
18184        NodeFailureCode::UnknownBundle => "unknown-bundle",
18185        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18186        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18187        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18188        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18189        NodeFailureCode::ContextPackMaterializationFailed => {
18190            "context-pack-materialization-failed"
18191        }
18192        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18193        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18194        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18195        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18196        NodeFailureCode::LastWorkspace => "last-workspace",
18197        NodeFailureCode::NotGitRepository => "not-git-repository",
18198        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18199        NodeFailureCode::WorktreeProtected => "worktree-protected",
18200        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18201        NodeFailureCode::WorktreeLocked => "worktree-locked",
18202        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18203        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18204        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18205        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18206            "managed-worktree-profile-revision-mismatch"
18207        }
18208        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18209        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18210            "standalone-workspace-recovery-required"
18211        }
18212        NodeFailureCode::UnknownSession => "unknown-session",
18213        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18214        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18215        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18216        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18217        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18218        NodeFailureCode::WorkspaceRegistrationRequired => {
18219            "workspace-registration-required"
18220        }
18221        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18222        NodeFailureCode::StaleGeneration => "stale-generation",
18223        NodeFailureCode::BackendBusy => "backend-busy",
18224        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18225        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18226        NodeFailureCode::ShuttingDown => "shutting-down",
18227    }
18228}
18229
18230fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18231    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18232}
18233
18234fn durable_state_load_error(error: io::Error) -> NodeServerError {
18235    let category = match session_registry::state_load_refusal(&error) {
18236        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18237            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18238        }
18239        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18240            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18241        }
18242        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18243            DURABLE_STATE_CONFLICT_ERROR
18244        }
18245        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18246    };
18247    durable_state_server_error(error, category)
18248}
18249
18250fn unix_time_ms() -> u64 {
18251    std::time::SystemTime::now()
18252        .duration_since(std::time::UNIX_EPOCH)
18253        .unwrap_or_default()
18254        .as_millis()
18255        .min(u64::MAX as u128) as u64
18256}
18257
18258fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18259    if record.provider_session.is_some() {
18260        ManagedSessionState::Dormant
18261    } else {
18262        ManagedSessionState::Unavailable
18263    }
18264}
18265
18266fn active_record_state(
18267    identity_admitted: bool,
18268    provider_session_is_known: bool,
18269) -> ManagedSessionState {
18270    if identity_admitted && !provider_session_is_known {
18271        ManagedSessionState::IdentityPending
18272    } else {
18273        ManagedSessionState::Live
18274    }
18275}
18276
18277fn managed_worktree_record_holders(
18278    records: &[ManagedSessionRecord],
18279) -> Vec<(SessionRecordId, WorkspaceId)> {
18280    records
18281        .iter()
18282        .filter(|record| record.provider_session.is_some())
18283        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18284        .collect()
18285}
18286
18287fn managed_spawn_record_ownership(
18288    binding: &SessionBinding,
18289    effective_runtime_policy: ProviderRuntimePolicy,
18290) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18291    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18292    if effective_runtime_policy.provider_session_identity {
18293        (binding.record_id.clone(), None)
18294    } else {
18295        (None, binding.record_id.clone())
18296    }
18297}
18298
18299#[derive(Debug, Error)]
18300pub enum NodeServerError {
18301    #[error("node endpoint must be a bounded absolute local transport path")]
18302    InvalidEndpoint,
18303    #[error("node access token must contain 1..=4096 bytes")]
18304    InvalidAccessToken,
18305    #[error("node HTTP observer must listen on a loopback address: {0}")]
18306    InvalidApiListen(std::net::SocketAddr),
18307    #[error("node browser bridge must listen on a loopback address: {0}")]
18308    InvalidBridgeListen(std::net::SocketAddr),
18309    #[error("node browser bridge token must contain 1..=4096 bytes")]
18310    InvalidBridgeToken,
18311    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18312    BridgeUnderlayRequiresBridgeListen,
18313    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18314    BridgeUnderlayRequiresBridgeToken,
18315    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18316    InvalidBridgeUnderlayToken,
18317    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18318    InvalidBridgeUnderlayKey,
18319    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18320    InvalidCallHome(std::net::SocketAddr),
18321    #[error("node could not reach its call-home relay: {0}")]
18322    CallHomeTransport(#[source] std::io::Error),
18323    #[error("node durable state path must be an absolute file path: {0}")]
18324    InvalidStatePath(String),
18325    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18326    InvalidHarnessMcpHelper,
18327    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18328    InvalidNetworkAllowlistCatalogPath(String),
18329    #[error("network allowlist catalog could not be read: {0}")]
18330    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18331    #[error("network allowlist catalog line {line}: {message}")]
18332    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18333    #[error("network allowlist catalog: {0}")]
18334    InvalidNetworkAllowlistCatalog(String),
18335    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18336    NetworkAllowlistCatalogCapacity { max: usize },
18337    #[error("the local state directory is unavailable; supply an explicit state path")]
18338    LocalStateDirectoryUnavailable,
18339    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18340    LocalRuntimeDirectoryUnavailable,
18341    #[error("node durable state failed: {0}")]
18342    DurableState(io::Error),
18343    #[error("node requires at least one configured workspace")]
18344    NoWorkspaces,
18345    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18346    InvalidWorkspaceRoot {
18347        workspace_id: WorkspaceId,
18348        path: String,
18349        message: String,
18350    },
18351    #[error("duplicate workspace ID: {0}")]
18352    DuplicateWorkspaceId(WorkspaceId),
18353    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18354    DuplicateWorkspaceRoot {
18355        first: WorkspaceId,
18356        second: WorkspaceId,
18357        root: String,
18358    },
18359    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18360    ManagedWorktreeProfileRequired(WorkspaceId),
18361    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18362    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18363    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18364    DuplicateManagedWorktreeProfile {
18365        workspace_id: WorkspaceId,
18366        profile_id: WorktreeProfileId,
18367    },
18368    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18369    ManagedWorktreeProfileCapacity {
18370        workspace_id: WorkspaceId,
18371        max: usize,
18372    },
18373    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18374    InvalidManagedWorktreeProfile {
18375        workspace_id: WorkspaceId,
18376        profile_id: WorktreeProfileId,
18377        message: String,
18378    },
18379    #[error("duplicate node environment profile: {0}")]
18380    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18381    #[error("node environment profile provider is unavailable: {0}")]
18382    EnvironmentProfileProviderUnavailable(AgentId),
18383    #[error("node environment profile capacity is {max}")]
18384    EnvironmentProfileCapacity { max: usize },
18385    #[error("node environment profiles reuse native profile ID '{0}'")]
18386    DuplicateNativeEnvironmentProfile(String),
18387    #[error("node native environment profile is invalid: {0}")]
18388    NativeEnvironmentProfile(String),
18389    #[error("node session-environment materialization root must be absolute")]
18390    InvalidSessionEnvironmentRoot,
18391    #[error("node session-environment materializer is unavailable")]
18392    SessionEnvironmentMaterializerRequired,
18393    #[error("node session-environment materializer failed to initialize")]
18394    SessionEnvironmentMaterializer,
18395    #[error("node bundle catalog is invalid: {0}")]
18396    BundleCatalog(String),
18397    #[error("node delivery store failed to initialize: {0}")]
18398    DeliveryStore(String),
18399    #[error("node context pack store failed to initialize")]
18400    ContextPackStore,
18401    #[error("active agent registry failed: {0}")]
18402    Registry(String),
18403    #[error("node provider contract manifest is invalid: {0}")]
18404    ProviderContractManifest(String),
18405    #[error("named pipe I/O failed: {0}")]
18406    Io(#[from] io::Error),
18407    #[error("node HTTP observer failed: {0}")]
18408    HttpApi(io::Error),
18409    #[error("node browser bridge failed: {0}")]
18410    BridgeApi(io::Error),
18411    #[error(transparent)]
18412    Frame(#[from] FrameError),
18413    #[error("node handshake failed: {0}")]
18414    Handshake(String),
18415    #[error("node authentication frame was not received before the bounded deadline")]
18416    AuthenticationTimedOut,
18417    #[error("node authentication primitive failed: {0}")]
18418    Authentication(String),
18419    #[error("node incarnation identity generation failed: {0}")]
18420    IncarnationIdentity(String),
18421    #[error("node pre-authentication limiter is closed")]
18422    PreauthClosed,
18423    #[error("node authenticated connection limit was reached")]
18424    AuthenticatedConnectionLimit,
18425    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18426    SignalStreamClosed,
18427    #[error("node stop-all dispatch failed: {0:?}")]
18428    ShutdownDispatch(NodeFailure),
18429    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18430    ShutdownTimedOut { active_native_sessions: usize },
18431}
18432
18433impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18434    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18435        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18436        match error {
18437            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18438            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18439            E::InvalidEntry { line, message } => {
18440                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18441            }
18442            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18443            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18444        }
18445    }
18446}
18447
18448#[cfg(test)]
18449mod agent_stream_blocked_tests {
18450    use super::*;
18451    use gate4agent_types::{
18452        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18453        ProviderSource,
18454    };
18455
18456    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18457        ProviderSource {
18458            family,
18459            binding: AdapterBinding::new(
18460                AdapterId::new(adapter).unwrap(),
18461                "fixture/v1",
18462                AdapterVerification::SyntheticFixture,
18463            )
18464            .unwrap(),
18465        }
18466    }
18467
18468    fn provider_control_event_at(
18469        family: AdapterFamily,
18470        adapter: &str,
18471        source_sequence: u64,
18472        provider_event: ProviderEvent,
18473    ) -> ControlEvent {
18474        ControlEvent {
18475            sequence: 41,
18476            command_id: None,
18477            instance_id: AgentInstanceId(7),
18478            generation: SessionGeneration(3),
18479            event: ControlEventKind::ProviderEvent {
18480                sequence: 8,
18481                source: provider_source(family, adapter),
18482                source_sequence,
18483                event: provider_event,
18484            },
18485        }
18486    }
18487
18488    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18489        provider_control_event_at(family, "fixture", 9, provider_event)
18490    }
18491
18492    fn denied_host_request(
18493        by: ProviderHostDecisionAuthority,
18494        reason: Option<String>,
18495    ) -> ControlEvent {
18496        provider_control_event_at(
18497            AdapterFamily::Acp,
18498            "claude-code",
18499            2,
18500            ProviderEvent::HostRequestObserved {
18501                method: "terminal/create".to_owned(),
18502                params_json: String::new(),
18503                decision: ProviderHostRequestDecision::Denied { by },
18504                outcome: ProviderHostRequestOutcome::Executed,
18505                reason,
18506            },
18507        )
18508    }
18509
18510    fn blocked(
18511        event: &ControlEvent,
18512    ) -> (
18513        Option<String>,
18514        String,
18515        BlockAuthorityV1,
18516        Option<String>,
18517        String,
18518        Option<String>,
18519    ) {
18520        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18521        chunk.validate().expect("minted chunk must validate");
18522        let AgentStreamChunkKindV1::Blocked {
18523            correlation_id,
18524            tool_class,
18525            authority,
18526            reason_kind,
18527            reason,
18528            help,
18529        } = chunk.kind
18530        else {
18531            panic!("expected AgentStreamChunkKindV1::Blocked");
18532        };
18533        (correlation_id, tool_class, authority, reason_kind, reason, help)
18534    }
18535
18536    /// Pins the fleet and every adapter each member declares: the fleet is
18537    /// exactly what `active_registry` says, and no member declares a hook
18538    /// adapter (lifecycle hooks are retired).
18539    #[test]
18540    fn the_enabled_provider_matrix_is_exact() {
18541        let registry = active_registry().unwrap();
18542        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18543        assert_eq!(
18544            providers
18545                .iter()
18546                .map(|contract| contract.provider.as_str())
18547                .collect::<Vec<_>>(),
18548            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18549            vec!["shell", "claude", "codex", "grok", "kimi"],
18550        );
18551        assert!(adapters.iter().all(|contract| {
18552            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18553        }));
18554    }
18555
18556    /// A gate-denied host request, WITH its reason text carried all the way
18557    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18558    #[test]
18559    fn gate_denied_host_request_mints_a_blocked_chunk() {
18560        let gate_text =
18561            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18562        let event = denied_host_request(
18563            ProviderHostDecisionAuthority::Gate,
18564            Some(gate_text.to_owned()),
18565        );
18566        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18567        assert_eq!(chunk.source_sequence, 2);
18568        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18569        assert_eq!(authority, BlockAuthorityV1::HostGate);
18570        assert_eq!(reason, gate_text);
18571        assert_eq!(correlation_id, None);
18572        assert_eq!(reason_kind, None);
18573        assert_eq!(help, None);
18574        assert_eq!(tool_class, "Write");
18575    }
18576
18577    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.";
18578
18579    /// The owner's classifier sample, verbatim, arriving as a
18580    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18581    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18582    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18583    /// and the guidance tail as `help`.
18584    #[test]
18585    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18586        let event = provider_control_event(
18587            AdapterFamily::Acp,
18588            ProviderEvent::ToolCompleted {
18589                id: "toolu_9".to_owned(),
18590                output: CLASSIFIER_SAMPLE.to_owned(),
18591                is_error: true,
18592                duration_ms: None,
18593                agent_id: None,
18594                non_execution_kind: Some("permission-rule".to_owned()),
18595            },
18596        );
18597        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18598        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18599        assert_eq!(reason, "Blocked by classifier");
18600        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18601        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18602        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18603    }
18604
18605    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18606    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18607    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18608    /// vendor code as `reason_kind`.
18609    #[test]
18610    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18611        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.";
18612        let event = provider_control_event(
18613            AdapterFamily::Acp,
18614            ProviderEvent::SessionEnded {
18615                result: "JSON-RPC error -32603: Internal error".to_owned(),
18616                cost_usd: None,
18617                is_error: true,
18618                stop_reason: Some(ProviderStopReason::ProviderError {
18619                    code: -32603,
18620                    message: message.to_owned(),
18621                    vendor_code: Some("usageLimitExceeded".to_owned()),
18622                }),
18623            },
18624        );
18625        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18626        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18627        assert_eq!(reason, message);
18628        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18629        assert_eq!(tool_class, "Session");
18630    }
18631
18632    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18633    /// `authority: ProviderRefusal`.
18634    #[test]
18635    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18636        let event = provider_control_event(
18637            AdapterFamily::Acp,
18638            ProviderEvent::SessionEnded {
18639                result: "refusal".to_owned(),
18640                cost_usd: None,
18641                is_error: true,
18642                stop_reason: Some(ProviderStopReason::Refusal),
18643            },
18644        );
18645        let (_, _, authority, _, _, _) = blocked(&event);
18646        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18647    }
18648
18649    /// A failed tool completion with no typed `non_execution_kind` -- or one
18650    /// that only says the turn stopped -- is a plain execution failure, never a
18651    /// block; nor is an agent's own narrative claiming one.
18652    #[test]
18653    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18654        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18655            let event = provider_control_event(
18656                AdapterFamily::Acp,
18657                ProviderEvent::ToolCompleted {
18658                    id: "toolu_1".to_owned(),
18659                    output: "command exited with status 1".to_owned(),
18660                    is_error: true,
18661                    duration_ms: Some(12),
18662                    agent_id: None,
18663                    non_execution_kind,
18664                },
18665            );
18666            assert!(agent_stream_chunk(&event).is_none());
18667        }
18668        let narrative = provider_control_event(
18669            AdapterFamily::Acp,
18670            ProviderEvent::ToolCompleted {
18671                id: "toolu_2".to_owned(),
18672                output: "the harness blocked the write, approval needed".to_owned(),
18673                is_error: false,
18674                duration_ms: Some(3),
18675                agent_id: None,
18676                non_execution_kind: None,
18677            },
18678        );
18679        assert!(agent_stream_chunk(&narrative).is_none());
18680    }
18681
18682    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18683    /// execution outcome: a granted request that failed while running was
18684    /// still authorized.
18685    #[test]
18686    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18687        for (decision, outcome) in [
18688            (
18689                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18690                ProviderHostRequestOutcome::Executed,
18691            ),
18692            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18693            (
18694                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18695                ProviderHostRequestOutcome::Failed {
18696                    error: "terminal/create spawn failed: os error 3".to_owned(),
18697                },
18698            ),
18699        ] {
18700            let event = provider_control_event_at(
18701                AdapterFamily::Acp,
18702                "claude-code",
18703                3,
18704                ProviderEvent::HostRequestObserved {
18705                    method: "fs/read_text_file".to_owned(),
18706                    params_json: String::new(),
18707                    decision: decision.clone(),
18708                    outcome,
18709                    reason: None,
18710                },
18711            );
18712            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18713        }
18714    }
18715
18716    /// A deferred `session/request_permission` resolved by an operator or an
18717    /// expired deadline carries no reason text -- the chunk falls back to a
18718    /// categorical sentence naming the authority rather than inventing detail.
18719    #[test]
18720    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18721        for (by, expected_authority, expected_reason) in [
18722            (
18723                ProviderHostDecisionAuthority::Operator,
18724                BlockAuthorityV1::Operator,
18725                "declined by the operator",
18726            ),
18727            (
18728                ProviderHostDecisionAuthority::DeadlinePolicy,
18729                BlockAuthorityV1::HostDeadline,
18730                "denied by host policy after the operator deadline expired",
18731            ),
18732        ] {
18733            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18734            assert_eq!(authority, expected_authority);
18735            assert_eq!(reason, expected_reason);
18736        }
18737    }
18738
18739    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18740    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18741    /// outright rather than truncating.
18742    #[test]
18743    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18744        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18745        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18746            ProviderHostDecisionAuthority::Policy,
18747            Some(oversized),
18748        ));
18749        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18750    }
18751}
18752
18753#[cfg(test)]
18754mod standalone_capability_platform_tests {
18755    use super::*;
18756
18757    #[test]
18758    fn standalone_workspace_capability_requires_windows_identity_guards() {
18759        let capabilities = baseline_capabilities().unwrap();
18760        let advertised = capabilities.iter().any(|capability| {
18761            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18762        });
18763        assert_eq!(advertised, cfg!(windows));
18764
18765        let request = NodeRequest::CreateStandaloneWorkspace {
18766            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18767            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18768            initial_branch: None,
18769        };
18770        assert_eq!(
18771            request_uses_unnegotiated_capability(&request, &capabilities),
18772            !cfg!(windows),
18773        );
18774    }
18775}
18776
18777
18778#[cfg(test)]
18779mod station_knobs_resolve_tests {
18780    use super::*;
18781    use gate4agent_catalog::EnvMutation;
18782    use gate4agent_runtime_native::{
18783        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18784        NativeLaunchProfileId,
18785    };
18786    use std::ffi::OsString;
18787    use std::sync::Arc;
18788
18789    struct EmptyEnvironmentResolver;
18790
18791    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18792        fn resolve_child_environment(
18793            &self,
18794        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18795            Ok(vec![EnvMutation {
18796                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18797                value: None,
18798            }])
18799        }
18800    }
18801
18802    fn agent(value: &str) -> AgentId {
18803        AgentId::new(value).unwrap()
18804    }
18805
18806    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18807        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18808        let profile_revision =
18809            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18810        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18811            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18812            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18813            provider: agent("claude"),
18814            mode: SessionMode::Pty,
18815            terminal_size: gate4agent_types::TerminalSize {
18816                rows: 24,
18817                columns: 80,
18818            },
18819            prompt: None,
18820            bundle_id: None,
18821            context_id: None,
18822            environment_profile_id: Some(profile_id.clone()),
18823        };
18824        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18825        let catalog = active_registry().unwrap();
18826        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18827        let control = runtime.native_launch_profile_control();
18828        let node_profile = NodeEnvironmentProfile::new(
18829            profile_id.clone(),
18830            profile_revision,
18831            agent("claude"),
18832            [NativeLaunchProfile::new(
18833                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18834                agent("claude"),
18835                TransportKind::Pty,
18836                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18837                Arc::new(EmptyEnvironmentResolver),
18838            )
18839            .unwrap()],
18840        )
18841        .unwrap();
18842        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18843        for native_profile in native_profiles {
18844            runtime.upsert_native_launch_profile(native_profile).unwrap();
18845        }
18846        let workspace = WorkspaceConfig::new(
18847            WorkspaceId::new("primary").unwrap(),
18848            std::env::current_dir().unwrap(),
18849        )
18850        .unwrap();
18851        let shared = NodeShared::new_with_incarnation(
18852            handle,
18853            "fixture-token".to_owned(),
18854            NodeId::new("node-station-knobs-test").unwrap(),
18855            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18856            vec![workspace],
18857            vec![agent("claude")],
18858            ProviderRuntimeStatuses::default(),
18859            None,
18860            Vec::new(),
18861            Vec::new(),
18862            spawn_profiles,
18863            Some(control),
18864            None,
18865            None,
18866            None,
18867            Vec::new(),
18868            Vec::new(),
18869            Vec::new(),
18870            Vec::new(),
18871            None,
18872            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18873        );
18874        shared
18875            .environment_profiles
18876            .write()
18877            .unwrap_or_else(|poisoned| poisoned.into_inner())
18878            .insert(binding.id.clone(), binding);
18879        (shared, spawn_defaults)
18880    }
18881
18882    /// Empty-default catalog refuses unknown network_allowlist ids.
18883    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18884    #[test]
18885    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18886        let (shared, defaults) = station_knobs_fixture();
18887        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18888        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18889        let spec = crate::protocol::SpawnSpec {
18890            target: crate::protocol::SpawnTarget {
18891                node_id: shared.node_id.clone(),
18892                workspace_id: WorkspaceId::new("primary").unwrap(),
18893                worktree_id: None,
18894            },
18895            profile_id: defaults.profile_id.clone(),
18896            expected_profile_revision: defaults.revision.clone(),
18897            overrides: crate::protocol::SpawnOverrides {
18898                network_allowlist: Some(unknown),
18899                browser_profile_id: Some(browser),
18900                ..crate::protocol::SpawnOverrides::default()
18901            },
18902            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18903            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18904                "station-knobs-unknown-allowlist",
18905            )
18906            .unwrap(),
18907            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18908        };
18909        let resolved = spec.resolve(&defaults).unwrap();
18910        let failure = shared
18911            .resolve_environment_profile(&resolved)
18912            .expect_err("unknown allowlist must refuse");
18913        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18914    }
18915
18916    /// Registered network_allowlist (+ browser_profile_id when feature off)
18917    /// echo onto the env-profile receipt. Empty catalog still allows omit
18918    /// (None). Empty/whitespace refuse is type-level. Feature-off
18919    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18920    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18921    #[test]
18922    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18923        let (shared, defaults) = station_knobs_fixture();
18924        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18925        #[cfg(not(feature = "dig2-station-probe"))]
18926        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18927        shared
18928            .network_allowlist_catalog
18929            .write()
18930            .unwrap_or_else(|poisoned| poisoned.into_inner())
18931            .insert_id(allowlist.clone())
18932            .unwrap();
18933        let spec = crate::protocol::SpawnSpec {
18934            target: crate::protocol::SpawnTarget {
18935                node_id: shared.node_id.clone(),
18936                workspace_id: WorkspaceId::new("primary").unwrap(),
18937                worktree_id: None,
18938            },
18939            profile_id: defaults.profile_id.clone(),
18940            expected_profile_revision: defaults.revision.clone(),
18941            overrides: crate::protocol::SpawnOverrides {
18942                network_allowlist: Some(allowlist.clone()),
18943                #[cfg(not(feature = "dig2-station-probe"))]
18944                browser_profile_id: Some(browser.clone()),
18945                ..crate::protocol::SpawnOverrides::default()
18946            },
18947            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18948            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18949                .unwrap(),
18950            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18951        };
18952        let resolved = spec.resolve(&defaults).unwrap();
18953        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18954        #[cfg(not(feature = "dig2-station-probe"))]
18955        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18956        let receipt = shared
18957            .resolve_environment_profile(&resolved)
18958            .expect("resolve ok")
18959            .expect("env profile present");
18960        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18961        #[cfg(not(feature = "dig2-station-probe"))]
18962        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18963        #[cfg(feature = "dig2-station-probe")]
18964        assert_eq!(receipt.browser_profile_id, None);
18965        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18966
18967        let omitted = crate::protocol::SpawnSpec {
18968            overrides: crate::protocol::SpawnOverrides::default(),
18969            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18970                .unwrap(),
18971            ..spec
18972        };
18973        let resolved_omit = omitted.resolve(&defaults).unwrap();
18974        let bare = shared
18975            .resolve_environment_profile(&resolved_omit)
18976            .expect("resolve ok")
18977            .expect("env profile present");
18978        assert_eq!(bare.network_allowlist, None);
18979        assert_eq!(bare.browser_profile_id, None);
18980    }
18981
18982    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18983    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18984    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18985    /// unit-tested separately (probe refuses before lease on Linux).
18986    #[cfg(feature = "dig2-station-probe")]
18987    #[test]
18988    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18989        let (shared, defaults) = station_knobs_fixture();
18990        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18991        let spec = crate::protocol::SpawnSpec {
18992            target: crate::protocol::SpawnTarget {
18993                node_id: shared.node_id.clone(),
18994                workspace_id: WorkspaceId::new("primary").unwrap(),
18995                worktree_id: None,
18996            },
18997            profile_id: defaults.profile_id.clone(),
18998            expected_profile_revision: defaults.revision.clone(),
18999            overrides: crate::protocol::SpawnOverrides {
19000                browser_profile_id: Some(browser),
19001                ..crate::protocol::SpawnOverrides::default()
19002            },
19003            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19004            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19005                "station-knobs-probe-refuse",
19006            )
19007            .unwrap(),
19008            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19009        };
19010        let resolved = spec.resolve(&defaults).unwrap();
19011        let failure = shared
19012            .resolve_environment_profile(&resolved)
19013            .expect_err("browser_profile with failing probe must refuse");
19014        #[cfg(windows)]
19015        {
19016            assert!(
19017                matches!(
19018                    failure.code,
19019                    NodeFailureCode::BrowserStationUnreachable
19020                        | NodeFailureCode::BrowserStationProbeUnavailable
19021                ),
19022                "unexpected code {:?}",
19023                failure.code
19024            );
19025        }
19026        #[cfg(not(windows))]
19027        {
19028            assert_eq!(
19029                failure.code,
19030                NodeFailureCode::BrowserStationProbeUnavailable
19031            );
19032        }
19033    }
19034
19035    /// Feature on: omitting browser_profile_id still resolves (no probe).
19036    #[cfg(feature = "dig2-station-probe")]
19037    #[test]
19038    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19039        let (shared, defaults) = station_knobs_fixture();
19040        let spec = crate::protocol::SpawnSpec {
19041            target: crate::protocol::SpawnTarget {
19042                node_id: shared.node_id.clone(),
19043                workspace_id: WorkspaceId::new("primary").unwrap(),
19044                worktree_id: None,
19045            },
19046            profile_id: defaults.profile_id.clone(),
19047            expected_profile_revision: defaults.revision.clone(),
19048            overrides: crate::protocol::SpawnOverrides::default(),
19049            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19050            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19051                "station-knobs-probe-omit",
19052            )
19053            .unwrap(),
19054            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19055        };
19056        let resolved = spec.resolve(&defaults).unwrap();
19057        let receipt = shared
19058            .resolve_environment_profile(&resolved)
19059            .expect("omit browser_profile must not probe")
19060            .expect("env profile present");
19061        assert_eq!(receipt.browser_profile_id, None);
19062    }
19063
19064    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19065    /// refuses a second holder for the same opaque profile id; release on
19066    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19067    #[cfg(feature = "dig2-station-probe")]
19068    #[test]
19069    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19070        let (shared, _defaults) = station_knobs_fixture();
19071        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19072        let workspace_id = WorkspaceId::new("primary").unwrap();
19073        let address_a = SessionAddress {
19074            workspace_id: workspace_id.clone(),
19075            session: SessionKey {
19076                instance_id: AgentInstanceId(101),
19077                generation: SessionGeneration(1),
19078            },
19079        };
19080        let address_b = SessionAddress {
19081            workspace_id: workspace_id.clone(),
19082            session: SessionKey {
19083                instance_id: AgentInstanceId(102),
19084                generation: SessionGeneration(1),
19085            },
19086        };
19087        let guard_a = crate::dig2_station_lease::acquire_lease(
19088            &shared.browser_station_leases,
19089            profile.clone(),
19090            address_a.session.instance_id,
19091            1,
19092        )
19093        .expect("first lease");
19094        let busy = crate::dig2_station_lease::acquire_lease(
19095            &shared.browser_station_leases,
19096            profile.clone(),
19097            address_b.session.instance_id,
19098            2,
19099        )
19100        .expect_err("second holder must refuse");
19101        assert!(matches!(
19102            busy,
19103            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19104        ));
19105        // Simulate a bound session so remove_binding clears the lease.
19106        shared.bind_session_with_policy(
19107            &address_a,
19108            ProviderRuntimePolicy::raw_pty(),
19109            None,
19110        );
19111        guard_a.retain();
19112        assert!(shared.remove_binding(&address_a).is_some());
19113        crate::dig2_station_lease::acquire_lease(
19114            &shared.browser_station_leases,
19115            profile,
19116            address_b.session.instance_id,
19117            3,
19118        )
19119        .expect("reacquire after clear")
19120        .retain();
19121        assert!(
19122            crate::dig2_station_lease::release_lease_for_holder(
19123                &shared.browser_station_leases,
19124                address_b.session.instance_id,
19125            )
19126        );
19127    }
19128
19129    /// Feature-off: no browser_station_leases field; stub echo documented in
19130    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19131    #[cfg(not(feature = "dig2-station-probe"))]
19132    #[test]
19133    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19134        let (shared, defaults) = station_knobs_fixture();
19135        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19136        let spec = crate::protocol::SpawnSpec {
19137            target: crate::protocol::SpawnTarget {
19138                node_id: shared.node_id.clone(),
19139                workspace_id: WorkspaceId::new("primary").unwrap(),
19140                worktree_id: None,
19141            },
19142            profile_id: defaults.profile_id.clone(),
19143            expected_profile_revision: defaults.revision.clone(),
19144            overrides: crate::protocol::SpawnOverrides {
19145                browser_profile_id: Some(browser.clone()),
19146                ..crate::protocol::SpawnOverrides::default()
19147            },
19148            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19149            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19150                .unwrap(),
19151            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19152        };
19153        let resolved = spec.resolve(&defaults).unwrap();
19154        let receipt = shared
19155            .resolve_environment_profile(&resolved)
19156            .expect("stub resolve")
19157            .expect("env profile");
19158        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19159    }
19160
19161
19162    /// Startup file loader registers ids into the empty-default catalog.
19163    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19164    #[test]
19165    fn network_allowlist_catalog_loads_from_temp_file() {
19166        let unique = std::time::SystemTime::now()
19167            .duration_since(std::time::UNIX_EPOCH)
19168            .unwrap()
19169            .as_nanos();
19170        let path = std::env::temp_dir().join(format!(
19171            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19172            std::process::id(),
19173        ));
19174        std::fs::write(
19175            &path,
19176            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19177        )
19178        .unwrap();
19179        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19180            .expect("temp catalog file must load");
19181        let _ = std::fs::remove_file(&path);
19182        assert_eq!(loaded.len(), 2);
19183        let (shared, defaults) = station_knobs_fixture();
19184        {
19185            let mut catalog = shared
19186                .network_allowlist_catalog
19187                .write()
19188                .unwrap_or_else(|poisoned| poisoned.into_inner());
19189            *catalog = loaded;
19190        }
19191        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19192        let spec = crate::protocol::SpawnSpec {
19193            target: crate::protocol::SpawnTarget {
19194                node_id: shared.node_id.clone(),
19195                workspace_id: WorkspaceId::new("primary").unwrap(),
19196                worktree_id: None,
19197            },
19198            profile_id: defaults.profile_id.clone(),
19199            expected_profile_revision: defaults.revision.clone(),
19200            overrides: crate::protocol::SpawnOverrides {
19201                network_allowlist: Some(allowlist.clone()),
19202                ..crate::protocol::SpawnOverrides::default()
19203            },
19204            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19205            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19206                "station-knobs-catalog-file",
19207            )
19208            .unwrap(),
19209            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19210        };
19211        let resolved = spec.resolve(&defaults).unwrap();
19212        let receipt = shared
19213            .resolve_environment_profile(&resolved)
19214            .expect("registered via file load")
19215            .expect("env profile present");
19216        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19217    }
19218
19219    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19220    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19221    #[test]
19222    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19223        let (shared, _defaults) = station_knobs_fixture();
19224        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19225        shared
19226            .network_allowlist_catalog
19227            .write()
19228            .unwrap_or_else(|poisoned| poisoned.into_inner())
19229            .insert_id(allowlist.clone())
19230            .unwrap();
19231        let snapshot = shared.snapshot();
19232        let inventory = snapshot
19233            .launch_inventory
19234            .expect("launch inventory present");
19235        let ids = inventory
19236            .network_allowlists
19237            .expect("non-empty catalog surfaces ids");
19238        assert_eq!(ids, vec![allowlist]);
19239    }
19240
19241    /// Track B: registered id with provider_native codex_network_access refuses
19242    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19243    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19244    #[test]
19245    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19246        let (shared, defaults) = station_knobs_fixture();
19247        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19248        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19249            id: allowlist.clone(),
19250            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19251                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19252                peer: "127.0.0.1:443".to_owned(),
19253            }],
19254            provider_native: Some(
19255                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19256                    codex_network_access: Some(true),
19257                },
19258            ),
19259        };
19260        shared
19261            .network_allowlist_catalog
19262            .write()
19263            .unwrap_or_else(|poisoned| poisoned.into_inner())
19264            .insert(entry)
19265            .unwrap();
19266        let spec = crate::protocol::SpawnSpec {
19267            target: crate::protocol::SpawnTarget {
19268                node_id: shared.node_id.clone(),
19269                workspace_id: WorkspaceId::new("primary").unwrap(),
19270                worktree_id: None,
19271            },
19272            profile_id: defaults.profile_id.clone(),
19273            expected_profile_revision: defaults.revision.clone(),
19274            overrides: crate::protocol::SpawnOverrides {
19275                network_allowlist: Some(allowlist),
19276                ..crate::protocol::SpawnOverrides::default()
19277            },
19278            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19279            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19280                "station-knobs-unsupported-mapping",
19281            )
19282            .unwrap(),
19283            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19284        };
19285        let resolved = spec.resolve(&defaults).unwrap();
19286        assert_eq!(resolved.provider.as_str(), "claude");
19287        let failure = shared
19288            .resolve_environment_profile(&resolved)
19289            .expect_err("claude + codex_network_access must refuse");
19290        assert_eq!(
19291            failure.code,
19292            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19293        );
19294    }
19295
19296    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19297    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19298    /// Never secrets on C2.
19299    #[test]
19300    fn resolve_codex_network_access_overlay_moderate_only() {
19301        let (shared, defaults) = station_knobs_fixture();
19302        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19303        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19304            id: allowlist.clone(),
19305            permits: Vec::new(),
19306            provider_native: Some(
19307                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19308                    codex_network_access: Some(true),
19309                },
19310            ),
19311        };
19312        shared
19313            .network_allowlist_catalog
19314            .write()
19315            .unwrap_or_else(|poisoned| poisoned.into_inner())
19316            .insert(entry)
19317            .unwrap();
19318
19319        let overlay = shared
19320            .resolve_codex_network_access_overlay_args(
19321                &agent("codex"),
19322                SessionMode::Pty,
19323                ApprovalLevel::Moderate,
19324                Some(&allowlist),
19325            )
19326            .expect("Moderate Codex maps network_access");
19327        assert_eq!(
19328            overlay,
19329            vec![
19330                std::ffi::OsString::from("-c"),
19331                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19332            ]
19333        );
19334
19335        let acp = shared
19336            .resolve_codex_network_access_overlay_args(
19337                &agent("codex"),
19338                SessionMode::Acp,
19339                ApprovalLevel::Moderate,
19340                Some(&allowlist),
19341            )
19342            .unwrap();
19343        assert_eq!(acp, overlay);
19344
19345        let inline = shared
19346            .resolve_codex_network_access_overlay_args(
19347                &agent("codex"),
19348                SessionMode::Inline,
19349                ApprovalLevel::Moderate,
19350                Some(&allowlist),
19351            )
19352            .unwrap_err();
19353        assert_eq!(
19354            inline.code,
19355            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19356        );
19357
19358        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19359            let spec = crate::protocol::SpawnSpec {
19360                target: crate::protocol::SpawnTarget {
19361                    node_id: shared.node_id.clone(),
19362                    workspace_id: WorkspaceId::new("primary").unwrap(),
19363                    worktree_id: None,
19364                },
19365                profile_id: defaults.profile_id.clone(),
19366                expected_profile_revision: defaults.revision.clone(),
19367                overrides: crate::protocol::SpawnOverrides {
19368                    provider: crate::protocol::SpawnOverride::Set {
19369                        value: agent("codex"),
19370                    },
19371                    approval_level: Some(level),
19372                    network_allowlist: Some(allowlist.clone()),
19373                    ..crate::protocol::SpawnOverrides::default()
19374                },
19375                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19376                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19377                    "codex-net-refuse-{}",
19378                    match level {
19379                        ApprovalLevel::ReadOnly => "ro",
19380                        ApprovalLevel::FullAuto => "fa",
19381                        _ => "other",
19382                    }
19383                ))
19384                .unwrap(),
19385                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19386            };
19387            let resolved = spec.resolve(&defaults).unwrap();
19388            let failure = shared
19389                .resolve_environment_profile(&resolved)
19390                .expect_err("RO/FullAuto must refuse");
19391            assert_eq!(
19392                failure.code,
19393                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19394            );
19395        }
19396    }
19397
19398    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19399    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19400    /// Never secrets on C2; does not invent provider_native Claude keys.
19401    #[test]
19402    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19403        let (shared, _defaults) = station_knobs_fixture();
19404        let allowlist =
19405            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19406        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19407            id: allowlist.clone(),
19408            permits: vec![
19409                crate::network_allowlist_catalog::NetworkPermitSketch {
19410                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19411                    peer: "github.com:443".to_owned(),
19412                },
19413                crate::network_allowlist_catalog::NetworkPermitSketch {
19414                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19415                    peer: "*.npmjs.org".to_owned(),
19416                },
19417            ],
19418            provider_native: None,
19419        };
19420        shared
19421            .network_allowlist_catalog
19422            .write()
19423            .unwrap_or_else(|poisoned| poisoned.into_inner())
19424            .insert(entry)
19425            .unwrap();
19426
19427        let unique = std::time::SystemTime::now()
19428            .duration_since(std::time::UNIX_EPOCH)
19429            .unwrap()
19430            .as_nanos();
19431        let provider_home = std::env::temp_dir().join(format!(
19432            "gate4agent-claude-provider-home-{}-{unique}",
19433            std::process::id(),
19434        ));
19435        std::fs::create_dir_all(&provider_home).unwrap();
19436
19437        let overlay = shared
19438            .resolve_network_allowlist_overlay_args(
19439                &agent("claude"),
19440                SessionMode::Pty,
19441                ApprovalLevel::Moderate,
19442                Some(&allowlist),
19443                Some(provider_home.as_path()),
19444            )
19445            .expect("Claude PTY settings overlay on unix");
19446        let settings_path = provider_home.join(
19447            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19448        );
19449        assert_eq!(
19450            overlay,
19451            vec![
19452                std::ffi::OsString::from("--settings"),
19453                std::ffi::OsString::from(settings_path.as_os_str()),
19454            ]
19455        );
19456        let body = std::fs::read_to_string(&settings_path).unwrap();
19457        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19458        assert_eq!(value["sandbox"]["enabled"], true);
19459        assert_eq!(
19460            value["sandbox"]["network"]["allowedDomains"],
19461            serde_json::json!(["github.com", "*.npmjs.org"])
19462        );
19463        assert!(settings_path.starts_with(&provider_home));
19464
19465        let acp = shared
19466            .resolve_network_allowlist_overlay_args(
19467                &agent("claude"),
19468                SessionMode::Acp,
19469                ApprovalLevel::Moderate,
19470                Some(&allowlist),
19471                Some(provider_home.as_path()),
19472            )
19473            .expect_err("Claude ACP must refuse settings overlay");
19474        assert_eq!(
19475            acp.code,
19476            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19477        );
19478
19479        let no_home = shared
19480            .resolve_network_allowlist_overlay_args(
19481                &agent("claude"),
19482                SessionMode::Pty,
19483                ApprovalLevel::Moderate,
19484                Some(&allowlist),
19485                None,
19486            )
19487            .expect_err("Claude overlay requires ProviderHome");
19488        assert_eq!(
19489            no_home.code,
19490            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19491        );
19492
19493        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19494        shared
19495            .network_allowlist_catalog
19496            .write()
19497            .unwrap_or_else(|poisoned| poisoned.into_inner())
19498            .insert_id(bare.clone())
19499            .unwrap();
19500        let empty = shared
19501            .resolve_network_allowlist_overlay_args(
19502                &agent("claude"),
19503                SessionMode::Pty,
19504                ApprovalLevel::Moderate,
19505                Some(&bare),
19506                Some(provider_home.as_path()),
19507            )
19508            .unwrap();
19509        assert!(empty.is_empty());
19510
19511        let _ = std::fs::remove_dir_all(&provider_home);
19512    }
19513
19514    /// Kimi / Grok with permits stay without invented argv overlays.
19515    #[test]
19516    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19517        let (shared, _defaults) = station_knobs_fixture();
19518        let allowlist =
19519            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19520        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19521            id: allowlist.clone(),
19522            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19523                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19524                peer: "example.com:443".to_owned(),
19525            }],
19526            provider_native: None,
19527        };
19528        shared
19529            .network_allowlist_catalog
19530            .write()
19531            .unwrap_or_else(|poisoned| poisoned.into_inner())
19532            .insert(entry)
19533            .unwrap();
19534        for provider in ["kimi", "grok"] {
19535            let overlay = shared
19536                .resolve_network_allowlist_overlay_args(
19537                    &agent(provider),
19538                    SessionMode::Pty,
19539                    ApprovalLevel::Moderate,
19540                    Some(&allowlist),
19541                    None,
19542                )
19543                .unwrap();
19544            assert!(
19545                overlay.is_empty(),
19546                "{provider} must not invent network argv from permits"
19547            );
19548        }
19549    }
19550
19551    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19552    #[test]
19553    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19554        let unique = std::time::SystemTime::now()
19555            .duration_since(std::time::UNIX_EPOCH)
19556            .unwrap()
19557            .as_nanos();
19558        let path = std::env::temp_dir().join(format!(
19559            "gate4agent-node-allowlist-json-{}-{unique}.json",
19560            std::process::id(),
19561        ));
19562        std::fs::write(
19563            &path,
19564            r#"{
19565              "schema_version": 2,
19566              "entries": [
19567                {
19568                  "id": "egress-default",
19569                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19570                },
19571                { "id": "lab-egress" }
19572              ]
19573            }"#,
19574        )
19575        .unwrap();
19576        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19577            .expect("json v2 catalog must load");
19578        let _ = std::fs::remove_file(&path);
19579        assert_eq!(loaded.len(), 2);
19580        let entry = loaded
19581            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19582            .unwrap();
19583        assert_eq!(entry.permits.len(), 1);
19584        let (shared, defaults) = station_knobs_fixture();
19585        {
19586            let mut catalog = shared
19587                .network_allowlist_catalog
19588                .write()
19589                .unwrap_or_else(|poisoned| poisoned.into_inner());
19590            *catalog = loaded;
19591        }
19592        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19593        let spec = crate::protocol::SpawnSpec {
19594            target: crate::protocol::SpawnTarget {
19595                node_id: shared.node_id.clone(),
19596                workspace_id: WorkspaceId::new("primary").unwrap(),
19597                worktree_id: None,
19598            },
19599            profile_id: defaults.profile_id.clone(),
19600            expected_profile_revision: defaults.revision.clone(),
19601            overrides: crate::protocol::SpawnOverrides {
19602                network_allowlist: Some(allowlist.clone()),
19603                ..crate::protocol::SpawnOverrides::default()
19604            },
19605            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19606            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19607                "station-knobs-catalog-json",
19608            )
19609            .unwrap(),
19610            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19611        };
19612        let resolved = spec.resolve(&defaults).unwrap();
19613        let receipt = shared
19614            .resolve_environment_profile(&resolved)
19615            .expect("permits-only entry ok for claude")
19616            .expect("env profile present");
19617        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19618        let inventory = shared
19619            .snapshot()
19620            .launch_inventory
19621            .expect("launch inventory present");
19622        let ids = inventory.network_allowlists.expect("ids surface");
19623        assert_eq!(
19624            ids,
19625            vec![
19626                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19627                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19628            ]
19629        );
19630    }
19631
19632
19633}
19634
19635
19636#[cfg(test)]
19637mod workspace_environment_collision_tests {
19638    use super::*;
19639    use crate::session_environment::{
19640        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19641    };
19642    use gate4agent_catalog::EnvMutation;
19643    use gate4agent_runtime_native::{
19644        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19645        NativeLaunchProfileId,
19646    };
19647    use std::ffi::OsString;
19648    use std::sync::Arc;
19649
19650    struct EmptyEnvironmentResolver;
19651
19652    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19653        fn resolve_child_environment(
19654            &self,
19655        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19656            Ok(vec![EnvMutation {
19657                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19658                value: None,
19659            }])
19660        }
19661    }
19662
19663    fn agent(value: &str) -> AgentId {
19664        AgentId::new(value).unwrap()
19665    }
19666
19667    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19668    fn claude_config_dir_collision_fixture() -> (
19669        NodeShared,
19670        ResolvedEnvironmentProfileReceipt,
19671        ResolvedEnvironmentProfileReceipt,
19672    ) {
19673        let catalog = active_registry().unwrap();
19674        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19675        let control = runtime.native_launch_profile_control();
19676        let workspace = WorkspaceConfig::new(
19677            WorkspaceId::new("primary").unwrap(),
19678            std::env::current_dir().unwrap(),
19679        )
19680        .unwrap();
19681        let shared = NodeShared::new_with_incarnation(
19682            handle,
19683            "fixture-token".to_owned(),
19684            NodeId::new("node-env-collision-test").unwrap(),
19685            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19686            vec![workspace],
19687            vec![agent("claude")],
19688            ProviderRuntimeStatuses::default(),
19689            None,
19690            Vec::new(),
19691            Vec::new(),
19692            SpawnProfileRegistry::default(),
19693            Some(control),
19694            None,
19695            None,
19696            None,
19697            Vec::new(),
19698            Vec::new(),
19699            Vec::new(),
19700            Vec::new(),
19701            None,
19702            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19703        );
19704
19705        let mut receipts = Vec::new();
19706        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19707            let profile_id =
19708                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19709            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19710                "claude-config-dir-{suffix}-r1"
19711            ))
19712            .unwrap();
19713            let materialization = NodeSessionMaterializationProfile::new(
19714                Vec::new(),
19715                vec![NodeSessionPathBinding::new(
19716                    "CLAUDE_CONFIG_DIR",
19717                    NodeSessionPathClass::ProviderHome,
19718                )
19719                .unwrap()],
19720                Vec::new(),
19721            )
19722            .unwrap();
19723            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19724                profile_id.clone(),
19725                profile_revision.clone(),
19726                agent("claude"),
19727                [NativeLaunchProfile::new(
19728                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19729                        .unwrap(),
19730                    agent("claude"),
19731                    TransportKind::Pty,
19732                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19733                    Arc::new(EmptyEnvironmentResolver),
19734                )
19735                .unwrap()],
19736                Some(materialization),
19737            )
19738            .unwrap();
19739            let (binding, native_profiles, materialization) = node_profile.into_parts();
19740            for native_profile in native_profiles {
19741                runtime.upsert_native_launch_profile(native_profile).unwrap();
19742            }
19743            shared
19744                .environment_profiles
19745                .write()
19746                .unwrap_or_else(|poisoned| poisoned.into_inner())
19747                .insert(binding.id.clone(), binding);
19748            shared
19749                .environment_materialization_profiles
19750                .write()
19751                .unwrap_or_else(|poisoned| poisoned.into_inner())
19752                .insert(profile_id.clone(), materialization.unwrap());
19753            receipts.push(ResolvedEnvironmentProfileReceipt {
19754                profile_id,
19755                profile_revision,
19756                network_allowlist: None,
19757                browser_profile_id: None,
19758            });
19759        }
19760        let profile_b = receipts.pop().unwrap();
19761        let profile_a = receipts.pop().unwrap();
19762        (shared, profile_a, profile_b)
19763    }
19764
19765    #[test]
19766    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19767        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19768        let primary = WorkspaceId::new("primary").unwrap();
19769        let worktree = WorkspaceId::new("worktree-b").unwrap();
19770        let address = SessionAddress {
19771            workspace_id: primary.clone(),
19772            session: SessionKey {
19773                instance_id: AgentInstanceId(41),
19774                generation: SessionGeneration(1),
19775            },
19776        };
19777        shared.bind_session_with_policy(
19778            &address,
19779            ProviderRuntimePolicy::raw_pty(),
19780            Some(profile_a.clone()),
19781        );
19782
19783        let refused = shared
19784            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19785            .unwrap_err();
19786        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19787
19788        // Some vs None on the same cwd is also a collision.
19789        let refused_none = shared
19790            .ensure_workspace_environment_profile_compatible(&primary, None)
19791            .unwrap_err();
19792        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19793
19794        // Same profile on the same workspace remains allowed.
19795        shared
19796            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19797            .unwrap();
19798
19799        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19800        shared
19801            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19802            .unwrap();
19803    }
19804
19805    #[tokio::test]
19806    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19807        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19808        let primary = WorkspaceId::new("primary").unwrap();
19809        let address = SessionAddress {
19810            workspace_id: primary.clone(),
19811            session: SessionKey {
19812                instance_id: AgentInstanceId(41),
19813                generation: SessionGeneration(1),
19814            },
19815        };
19816        shared.bind_session_with_policy(
19817            &address,
19818            ProviderRuntimePolicy::raw_pty(),
19819            Some(profile_a),
19820        );
19821
19822        let failure = shared
19823            .spawn_session_with_deadline(
19824                primary,
19825                agent("claude"),
19826                SessionMode::Pty,
19827                gate4agent_types::TerminalSize {
19828                    rows: 24,
19829                    columns: 80,
19830                },
19831                None,
19832                Some(profile_b),
19833                None,
19834                None,
19835                None,
19836                None,
19837                SpawnRecordPolicy::ProviderIdentityOnly,
19838                Some(Instant::now() + Duration::from_secs(30)),
19839                &[],
19840                None,
19841                ApprovalLevel::default(),
19842                None,
19843                None,
19844            )
19845            .await
19846            .unwrap_err();
19847        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19848        // Guard runs before instance id mint / bind.
19849        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19850        assert!(shared.handle.snapshot().sessions.is_empty());
19851    }
19852}
19853
19854#[cfg(test)]
19855mod spawn_characterization_tests {
19856    use super::*;
19857    use crate::session_environment::{
19858        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19859        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19860        NodeSessionPathClass,
19861    };
19862    use gate4agent_catalog::EnvMutation;
19863    use gate4agent_runtime_native::{
19864        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19865        NativeLaunchProfile, NativeLaunchProfileId,
19866    };
19867    use std::ffi::OsString;
19868    use std::sync::Arc;
19869
19870    struct EmptyEnvironmentResolver;
19871
19872    struct FixtureSecretResolver {
19873        deny: Arc<AtomicBool>,
19874    }
19875
19876    impl NodeSecretResolver for FixtureSecretResolver {
19877        fn resolve(
19878            &self,
19879            _reference: &NodeSecretReference,
19880        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19881            if self.deny.load(Ordering::Acquire) {
19882                Err(NodeSecretResolveError::Denied)
19883            } else {
19884                NodeSecretValue::text("fixture-secret")
19885                    .map_err(|_| NodeSecretResolveError::Unavailable)
19886            }
19887        }
19888    }
19889
19890    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19891        fn resolve_child_environment(
19892            &self,
19893        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19894            Ok(vec![EnvMutation {
19895                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19896                value: None,
19897            }])
19898        }
19899    }
19900
19901    fn agent(value: &str) -> AgentId {
19902        AgentId::new(value).unwrap()
19903    }
19904
19905    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19906        let unique = std::time::SystemTime::now()
19907            .duration_since(std::time::UNIX_EPOCH)
19908            .unwrap()
19909            .as_nanos();
19910        std::env::temp_dir().join(format!(
19911            "gate4agent-node-{label}-{}-{unique}",
19912            std::process::id(),
19913        ))
19914    }
19915
19916    fn materialization_test_shared(
19917        deny_secret: bool,
19918    ) -> (
19919        NodeShared,
19920        NativeRuntime,
19921        ResolvedEnvironmentProfileReceipt,
19922        PathBuf,
19923        Arc<AtomicBool>,
19924    ) {
19925        let workspace = WorkspaceConfig::new(
19926            WorkspaceId::new("primary").unwrap(),
19927            std::env::current_dir().unwrap(),
19928        )
19929        .unwrap();
19930        materialization_test_shared_with_workspace(deny_secret, workspace)
19931    }
19932
19933    fn materialization_test_shared_with_workspace(
19934        deny_secret: bool,
19935        workspace: WorkspaceConfig,
19936    ) -> (
19937        NodeShared,
19938        NativeRuntime,
19939        ResolvedEnvironmentProfileReceipt,
19940        PathBuf,
19941        Arc<AtomicBool>,
19942    ) {
19943        let root = temporary_workspace_root(if deny_secret {
19944            "materialization-denied"
19945        } else {
19946            "materialization-ready"
19947        });
19948        std::fs::create_dir_all(&root).unwrap();
19949        let materialization_root = root.join("session-environments");
19950        let deny = Arc::new(AtomicBool::new(deny_secret));
19951        let resolver = Arc::new(FixtureSecretResolver {
19952            deny: Arc::clone(&deny),
19953        });
19954        let materializer = SessionEnvironmentMaterializer::new(
19955            materialization_root,
19956            resolver,
19957        )
19958        .unwrap();
19959        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19960        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19961            "materialized-claude-r1",
19962        )
19963        .unwrap();
19964        let materialization = NodeSessionMaterializationProfile::new(
19965            vec![NodeSessionEnvironmentMutation::SetSecret {
19966                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19967                reference: NodeSecretReference::new("fixture-token").unwrap(),
19968            }],
19969            vec![NodeSessionPathBinding::new(
19970                "GATE4AGENT_PROVIDER_HOME",
19971                NodeSessionPathClass::ProviderHome,
19972            )
19973            .unwrap()],
19974            vec![NodeSessionFile::secret(
19975                NodeSessionPathClass::Config,
19976                "auth/token",
19977                NodeSecretReference::new("fixture-token").unwrap(),
19978            )
19979            .unwrap()],
19980        )
19981        .unwrap();
19982        let catalog = active_registry().unwrap();
19983        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19984        let control = runtime.native_launch_profile_control();
19985        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19986            profile_id.clone(),
19987            profile_revision.clone(),
19988            agent("claude"),
19989            [NativeLaunchProfile::new(
19990                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19991                agent("claude"),
19992                TransportKind::Pty,
19993                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19994                Arc::new(EmptyEnvironmentResolver),
19995            )
19996            .unwrap()],
19997            Some(materialization),
19998        )
19999        .unwrap();
20000        let (binding, native_profiles, materialization) = node_profile.into_parts();
20001        for native_profile in native_profiles {
20002            runtime.upsert_native_launch_profile(native_profile).unwrap();
20003        }
20004        let shared = NodeShared::new_with_incarnation(
20005            handle,
20006            "fixture-token".to_owned(),
20007            NodeId::new("node-materialization-test").unwrap(),
20008            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20009            vec![workspace],
20010            vec![agent("claude")],
20011            ProviderRuntimeStatuses::default(),
20012            None,
20013            Vec::new(),
20014            Vec::new(),
20015            SpawnProfileRegistry::default(),
20016            Some(control),
20017            None,
20018            Some(materializer),
20019            None,
20020            Vec::new(),
20021            Vec::new(),
20022            Vec::new(),
20023            Vec::new(),
20024            None,
20025            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20026        );
20027        shared
20028            .environment_profiles
20029            .write()
20030            .unwrap_or_else(|poisoned| poisoned.into_inner())
20031            .insert(binding.id.clone(), binding);
20032        shared
20033            .environment_materialization_profiles
20034            .write()
20035            .unwrap_or_else(|poisoned| poisoned.into_inner())
20036            .insert(profile_id.clone(), materialization.unwrap());
20037        (
20038            shared,
20039            runtime,
20040            ResolvedEnvironmentProfileReceipt {
20041                profile_id,
20042                profile_revision,
20043                network_allowlist: None,
20044                browser_profile_id: None,
20045            },
20046            root,
20047            deny,
20048        )
20049    }
20050
20051
20052    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20053
20054    async fn characterization_spawn(
20055        shared: &NodeShared,
20056        mode: SessionMode,
20057        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20058        required: &[ProviderRuntimeCapability],
20059        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20060    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20061        shared
20062            .spawn_session_with_deadline(
20063                WorkspaceId::new("primary").unwrap(),
20064                agent("claude"),
20065                mode,
20066                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20067                None,
20068                environment_profile,
20069                None,
20070                None,
20071                None,
20072                Some(ProviderRuntimePolicy::raw_pty()),
20073                SpawnRecordPolicy::ProviderIdentityOnly,
20074                Some(Instant::now() + Duration::from_secs(30)),
20075                required,
20076                harness_mcp,
20077                ApprovalLevel::default(),
20078                None,
20079                None,
20080            )
20081            .await
20082    }
20083
20084    fn assert_no_session_state(shared: &NodeShared) {
20085        assert!(shared.handle.snapshot().sessions.is_empty());
20086        assert!(shared
20087            .session_bindings
20088            .lock()
20089            .unwrap_or_else(|poisoned| poisoned.into_inner())
20090            .is_empty());
20091        assert!(shared
20092            .materializations
20093            .lock()
20094            .unwrap_or_else(|poisoned| poisoned.into_inner())
20095            .is_empty());
20096    }
20097
20098    #[tokio::test]
20099    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20100        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20101        let error = characterization_spawn(
20102            &shared,
20103            SessionMode::Pty,
20104            None,
20105            &[ProviderRuntimeCapability::SemanticReadiness],
20106            None,
20107        )
20108        .await
20109        .unwrap_err();
20110        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20111        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20112        assert_no_session_state(&shared);
20113        drop(shared);
20114        drop(runtime);
20115        std::fs::remove_dir_all(root).unwrap();
20116    }
20117
20118    #[tokio::test]
20119    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20120        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20121        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20122            .await
20123            .unwrap_err();
20124        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20125        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20126        assert_no_session_state(&shared);
20127        drop(shared);
20128        drop(runtime);
20129        std::fs::remove_dir_all(root).unwrap();
20130    }
20131
20132    #[test]
20133    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20134        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20135        let address = SessionAddress {
20136            workspace_id: WorkspaceId::new("primary").unwrap(),
20137            session: SessionKey {
20138                instance_id: AgentInstanceId(77),
20139                generation: SessionGeneration(1),
20140            },
20141        };
20142        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20143        let removed = shared.remove_binding(&address).expect("binding removed");
20144        assert!(removed.environment_profile.is_some());
20145        assert!(shared.remove_binding(&address).is_none());
20146        assert_no_session_state(&shared);
20147        drop(shared);
20148        drop(runtime);
20149        std::fs::remove_dir_all(root).unwrap();
20150    }
20151
20152    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20153        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20154        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20155        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20156            .expect("review current test executable as fixture helper");
20157        let registry = HarnessMcpProxyRegistry::new(helper);
20158        let spec = crate::protocol::SpawnSpec {
20159            target: crate::protocol::SpawnTarget {
20160                node_id: NodeId::new("node-characterization").unwrap(),
20161                workspace_id: WorkspaceId::new("primary").unwrap(),
20162                worktree_id: None,
20163            },
20164            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20165            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20166            overrides: crate::protocol::SpawnOverrides::default(),
20167            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20168            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20169            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20170        };
20171        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20172        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20173        let launch = HarnessMcpLaunchV1 {
20174            server_name: "fixture-harness".to_owned(),
20175            args: vec!["--session-proxy".to_owned()],
20176            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20177            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20178            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20179            trace: None,
20180            scrub_env: Vec::new(),
20181        };
20182        let expires = unix_time_ms().saturating_add(30_000);
20183        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20184        registry
20185            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20186            .unwrap()
20187    }
20188
20189    #[tokio::test]
20190    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20191        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20192        let prepared = characterization_prepared_harness("claude").await;
20193        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20194            .await
20195            .unwrap_err();
20196        // Inline is stopped by runtime admission before the harness check runs.
20197        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20198        let mismatched = characterization_prepared_harness("codex").await;
20199        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20200            .await
20201            .unwrap_err();
20202        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20203        assert_no_session_state(&shared);
20204        drop(shared);
20205        drop(runtime);
20206        std::fs::remove_dir_all(root).unwrap();
20207    }
20208
20209    #[tokio::test]
20210    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20211        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20212        shared.native_launch_profile_control = None;
20213        let prepared = characterization_prepared_harness("claude").await;
20214        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20215            .await
20216            .unwrap_err();
20217        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20218        assert_no_session_state(&shared);
20219        drop(shared);
20220        drop(runtime);
20221        std::fs::remove_dir_all(root).unwrap();
20222    }
20223}
20224
20225#[cfg(all(test, windows))]
20226mod tests {
20227    use super::*;
20228    use gate4agent_types::ProviderActivity;
20229    use crate::session_environment::{
20230        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20231        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20232        NodeSessionPathClass,
20233    };
20234    use gate4agent_catalog::EnvMutation;
20235    use gate4agent_runtime_native::{
20236        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20237        NativeLaunchProfile, NativeLaunchProfileId,
20238    };
20239    use std::ffi::OsString;
20240    use std::sync::Arc;
20241
20242    struct EmptyEnvironmentResolver;
20243
20244    struct FixtureSecretResolver {
20245        deny: Arc<AtomicBool>,
20246    }
20247
20248    impl NodeSecretResolver for FixtureSecretResolver {
20249        fn resolve(
20250            &self,
20251            _reference: &NodeSecretReference,
20252        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20253            if self.deny.load(Ordering::Acquire) {
20254                Err(NodeSecretResolveError::Denied)
20255            } else {
20256                NodeSecretValue::text("fixture-secret")
20257                    .map_err(|_| NodeSecretResolveError::Unavailable)
20258            }
20259        }
20260    }
20261
20262    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20263        fn resolve_child_environment(
20264            &self,
20265        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20266            Ok(vec![EnvMutation {
20267                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20268                value: None,
20269            }])
20270        }
20271    }
20272
20273    fn agent(value: &str) -> AgentId {
20274        AgentId::new(value).unwrap()
20275    }
20276
20277    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20278        let catalog = active_registry().unwrap();
20279        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20280        let workspace = WorkspaceConfig::new(
20281            WorkspaceId::new("primary").unwrap(),
20282            std::env::current_dir().unwrap(),
20283        )
20284        .unwrap()
20285        .with_worktree_service_mode(mode);
20286        NodeShared::new(
20287            handle,
20288            "fixture-token".to_owned(),
20289            NodeId::new("node-terminal-test").unwrap(),
20290            vec![workspace],
20291            vec![agent("claude")],
20292        )
20293    }
20294
20295    fn terminal_test_shared() -> NodeShared {
20296        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20297    }
20298
20299    #[cfg(feature = "fixture")]
20300    #[test]
20301    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20302        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20303        assert_eq!(probe.latest_code(), None);
20304
20305        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20306        assert_eq!(
20307            probe.latest_code(),
20308            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20309        );
20310
20311        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20312        assert_eq!(
20313            probe.latest_code(),
20314            Some(NodeFailureCode::SpawnDeadlineExceeded),
20315        );
20316
20317        probe.record(None);
20318        assert_eq!(probe.latest_code(), None);
20319    }
20320
20321    #[test]
20322    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20323        let source = temporary_workspace_root("launch-inventory-source");
20324        let allocation = temporary_workspace_root("launch-inventory-allocation");
20325        std::fs::create_dir_all(&source).unwrap();
20326        std::fs::create_dir_all(&allocation).unwrap();
20327        let workspace_id = WorkspaceId::new("primary").unwrap();
20328        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20329            .unwrap()
20330            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20331        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20332            workspace = workspace
20333                .with_managed_worktree_profile(
20334                    ManagedWorktreeProfile::new(
20335                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20336                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20337                        &allocation,
20338                        "gate4agent",
20339                        "HEAD",
20340                        ManagedWorktreeRetention::RemoveWhenReleased,
20341                    )
20342                    .unwrap(),
20343                )
20344                .unwrap();
20345        }
20346        let overflow = workspace.clone().with_managed_worktree_profile(
20347            ManagedWorktreeProfile::new(
20348                WorktreeProfileId::new("overflow").unwrap(),
20349                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20350                &allocation,
20351                "gate4agent",
20352                "HEAD",
20353                ManagedWorktreeRetention::Retain,
20354            )
20355            .unwrap(),
20356        );
20357        assert!(matches!(
20358            overflow,
20359            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20360                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20361                ..
20362            }),
20363        ));
20364
20365        let catalog = active_registry().unwrap();
20366        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20367        let shared = NodeShared::new(
20368            handle,
20369            "fixture-token".to_owned(),
20370            NodeId::new("node-launch-inventory").unwrap(),
20371            vec![workspace],
20372            vec![agent("claude")],
20373        );
20374        let snapshot = shared.snapshot();
20375        let launch = snapshot.launch_inventory.as_ref().unwrap();
20376        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20377        assert!(launch.bundles.as_ref().unwrap().is_empty());
20378        let profiles = snapshot.workspaces[0]
20379            .managed_worktree_profiles
20380            .as_ref()
20381            .unwrap();
20382        assert_eq!(
20383            snapshot.workspaces[0].worktree_service_mode,
20384            Some(WorktreeServiceMode::Managed),
20385        );
20386        assert_eq!(
20387            profiles.profiles.len(),
20388            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20389        );
20390        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20391        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20392            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20393        }
20394        let legacy = snapshot_for_wire(
20395            &shared,
20396            true,
20397            true,
20398            false,
20399            false,
20400            true,
20401            false,
20402            true,
20403            false,
20404            true,
20405        );
20406        assert!(legacy.launch_inventory.is_none());
20407        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20408
20409        drop(shared);
20410        std::fs::remove_dir_all(source).unwrap();
20411        std::fs::remove_dir_all(allocation).unwrap();
20412    }
20413
20414    #[test]
20415    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20416        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20417            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20418        }));
20419        let source = gate4agent_types::ProviderSource {
20420            family: AdapterFamily::PtySemantic,
20421            binding: AdapterBinding::new(
20422                gate4agent_types::AdapterId::new("claude").unwrap(),
20423                "fixture/v1",
20424                gate4agent_types::AdapterVerification::SyntheticFixture,
20425            )
20426            .unwrap(),
20427        };
20428        let provider = ProviderSnapshot {
20429            sequence: 17,
20430            session: Some(ProviderSessionIdentity {
20431                key: ProviderSessionKey::SessionId,
20432                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20433                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20434            }),
20435            model: Some("PRIVATE_MODEL".to_owned()),
20436            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20437            completed_turns: 3,
20438            usage: gate4agent_types::TokenUsage {
20439                input_tokens: 10,
20440                output_tokens: 20,
20441                cache_read_tokens: 30,
20442                cache_write_tokens: 40,
20443                reasoning_tokens: 50,
20444                context_window: Some(200_000),
20445            },
20446            lead_activity: ProviderActivity::WaitingForInput,
20447            activity: ProviderActivity::WaitingForInput,
20448            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20449            active_tools: (0..9)
20450                .map(|index| gate4agent_types::ActiveProviderTool {
20451                    id: format!("private-tool-id-{index}"),
20452                    name: if index == 0 {
20453                        format!("  Read\u{0000}{}", "x".repeat(80))
20454                    } else {
20455                        format!("Tool-{index}")
20456                    },
20457                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20458                })
20459                .collect(),
20460            interactions: vec![gate4agent_types::ProviderInteraction {
20461                id: gate4agent_types::ProviderInteractionId(17),
20462                source: source.clone(),
20463                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20464                interaction_kind: ProviderInteractionKind::Approval,
20465                tool_name: " Approve\u{0007}Tool ".to_owned(),
20466                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20467                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20468                resume_lead_activity: Some(ProviderActivity::Working),
20469                status: ProviderInteractionStatus::Pending,
20470            }],
20471            subagents: vec![gate4agent_types::ProviderSubagent {
20472                source: source.clone(),
20473                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20474                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20475                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20476            }],
20477            sources: vec![gate4agent_types::ProviderSourceCursor {
20478                source,
20479                sequence: 17,
20480                gap_count: 2,
20481                stale: true,
20482            }],
20483            last_event: Some(ProviderEvent::Error {
20484                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20485            }),
20486            gap_count: 2,
20487            stale: true,
20488        };
20489        let address = terminal_address(3);
20490        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20491        assert_eq!(entry.address, address);
20492        assert_eq!(entry.progress.provider_sequence, 17);
20493        assert_eq!(entry.progress.active_tool_count, 9);
20494        assert_eq!(
20495            entry.progress.active_tool_labels.len(),
20496            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20497        );
20498        assert_eq!(entry.progress.subagent_count, 1);
20499        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20500        assert!(entry.progress.truncated);
20501        let encoded = serde_json::to_string(&entry).unwrap();
20502        for forbidden in [
20503            "PRIVATE_PROVIDER_SESSION",
20504            "PRIVATE_PROVIDER_PATH",
20505            "PRIVATE_MODEL",
20506            "PRIVATE_TOOL_INVENTORY",
20507            "PRIVATE_CURRENT_PROMPT",
20508            "PRIVATE_TOOL_INPUT_JSON",
20509            "PRIVATE_PROVIDER_REQUEST",
20510            "PRIVATE_INTERACTION_PROMPT",
20511            "PRIVATE_INTERACTION_AGENT",
20512            "PRIVATE_SUBAGENT_ID",
20513            "PRIVATE_SUBAGENT_TYPE",
20514            "PRIVATE_SUBAGENT_DESCRIPTION",
20515            "PRIVATE_PROVIDER_ERROR",
20516            "input_json",
20517            "current_prompt",
20518            "session_id",
20519        ] {
20520            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20521        }
20522
20523        let snapshot = NodeSnapshot {
20524            node_id: NodeId::new("node-progress-test").unwrap(),
20525            enabled_providers: vec![agent("claude")],
20526            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20527            workspaces: Vec::new(),
20528            session_records: Vec::new(),
20529            managed_worktrees: Vec::new(),
20530            launch_inventory: None,
20531            agent_progress: vec![entry],
20532        };
20533        let mut legacy_reply = ResponseEnvelope {
20534            request_id: 1,
20535            result: Ok(NodeResponse::Snapshot {
20536                event_sequence: 0,
20537                controller: None,
20538                snapshot: snapshot.clone(),
20539            }),
20540        };
20541        project_response_without_agent_progress(&mut legacy_reply);
20542        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20543            panic!("agent progress projection changed response kind");
20544        };
20545        assert!(legacy.agent_progress.is_empty());
20546        assert_eq!(snapshot.agent_progress.len(), 1);
20547    }
20548
20549    fn terminal_address(generation: u64) -> SessionAddress {
20550        SessionAddress {
20551            workspace_id: WorkspaceId::new("primary").unwrap(),
20552            session: SessionKey {
20553                instance_id: AgentInstanceId(41),
20554                generation: SessionGeneration(generation),
20555            },
20556        }
20557    }
20558
20559    fn spawn_spec_fixture() -> SpawnSpec {
20560        SpawnSpec {
20561            target: crate::protocol::SpawnTarget {
20562                node_id: NodeId::new("node-terminal-test").unwrap(),
20563                workspace_id: WorkspaceId::new("primary").unwrap(),
20564                worktree_id: None,
20565            },
20566            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20567            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20568                .unwrap(),
20569            overrides: crate::protocol::SpawnOverrides::default(),
20570            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20571            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20572            required_capabilities: SpawnRequiredCapabilities::default(),
20573        }
20574    }
20575
20576    fn environment_profile_test_shared(
20577    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20578        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20579        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20580            "local-claude-r1",
20581        )
20582        .unwrap();
20583        let spawn_profiles = SpawnProfileRegistry::new([
20584            crate::protocol::SpawnProfileDefaults {
20585                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20586                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20587                provider: agent("claude"),
20588                mode: SessionMode::Pty,
20589                terminal_size: gate4agent_types::TerminalSize {
20590                    rows: 24,
20591                    columns: 80,
20592                },
20593                prompt: None,
20594                bundle_id: None,
20595                context_id: None,
20596                environment_profile_id: Some(profile_id.clone()),
20597            },
20598        ])
20599        .unwrap();
20600        let catalog = active_registry().unwrap();
20601        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20602        let control = runtime.native_launch_profile_control();
20603        let node_profile = NodeEnvironmentProfile::new(
20604            profile_id.clone(),
20605            profile_revision.clone(),
20606            agent("claude"),
20607            [NativeLaunchProfile::new(
20608                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20609                agent("claude"),
20610                TransportKind::Pty,
20611                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20612                Arc::new(EmptyEnvironmentResolver),
20613            )
20614            .unwrap()],
20615        )
20616        .unwrap();
20617        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20618        for native_profile in native_profiles {
20619            runtime.upsert_native_launch_profile(native_profile).unwrap();
20620        }
20621        let workspace = WorkspaceConfig::new(
20622            WorkspaceId::new("primary").unwrap(),
20623            std::env::current_dir().unwrap(),
20624        )
20625        .unwrap();
20626        let shared = NodeShared::new_with_incarnation(
20627            handle,
20628            "fixture-token".to_owned(),
20629            NodeId::new("node-terminal-test").unwrap(),
20630            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20631            vec![workspace],
20632            vec![agent("claude")],
20633            ProviderRuntimeStatuses::default(),
20634            None,
20635            Vec::new(),
20636            Vec::new(),
20637            spawn_profiles,
20638            Some(control),
20639            None,
20640            None,
20641            None,
20642            Vec::new(),
20643            Vec::new(),
20644            Vec::new(),
20645            Vec::new(),
20646            None,
20647            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20648        );
20649        shared
20650            .environment_profiles
20651            .write()
20652            .unwrap_or_else(|poisoned| poisoned.into_inner())
20653            .insert(binding.id.clone(), binding);
20654        (
20655            shared,
20656            runtime,
20657            ResolvedEnvironmentProfileReceipt {
20658                profile_id,
20659                profile_revision,
20660                network_allowlist: None,
20661                browser_profile_id: None,
20662            },
20663        )
20664    }
20665
20666    fn materialization_test_shared(
20667        deny_secret: bool,
20668    ) -> (
20669        NodeShared,
20670        NativeRuntime,
20671        ResolvedEnvironmentProfileReceipt,
20672        PathBuf,
20673        Arc<AtomicBool>,
20674    ) {
20675        let workspace = WorkspaceConfig::new(
20676            WorkspaceId::new("primary").unwrap(),
20677            std::env::current_dir().unwrap(),
20678        )
20679        .unwrap();
20680        materialization_test_shared_with_workspace(deny_secret, workspace)
20681    }
20682
20683    fn materialization_test_shared_with_workspace(
20684        deny_secret: bool,
20685        workspace: WorkspaceConfig,
20686    ) -> (
20687        NodeShared,
20688        NativeRuntime,
20689        ResolvedEnvironmentProfileReceipt,
20690        PathBuf,
20691        Arc<AtomicBool>,
20692    ) {
20693        let root = temporary_workspace_root(if deny_secret {
20694            "materialization-denied"
20695        } else {
20696            "materialization-ready"
20697        });
20698        std::fs::create_dir_all(&root).unwrap();
20699        let materialization_root = root.join("session-environments");
20700        let deny = Arc::new(AtomicBool::new(deny_secret));
20701        let resolver = Arc::new(FixtureSecretResolver {
20702            deny: Arc::clone(&deny),
20703        });
20704        let materializer = SessionEnvironmentMaterializer::new(
20705            materialization_root,
20706            resolver,
20707        )
20708        .unwrap();
20709        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20710        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20711            "materialized-claude-r1",
20712        )
20713        .unwrap();
20714        let materialization = NodeSessionMaterializationProfile::new(
20715            vec![NodeSessionEnvironmentMutation::SetSecret {
20716                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20717                reference: NodeSecretReference::new("fixture-token").unwrap(),
20718            }],
20719            vec![NodeSessionPathBinding::new(
20720                "GATE4AGENT_PROVIDER_HOME",
20721                NodeSessionPathClass::ProviderHome,
20722            )
20723            .unwrap()],
20724            vec![NodeSessionFile::secret(
20725                NodeSessionPathClass::Config,
20726                "auth/token",
20727                NodeSecretReference::new("fixture-token").unwrap(),
20728            )
20729            .unwrap()],
20730        )
20731        .unwrap();
20732        let catalog = active_registry().unwrap();
20733        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20734        let control = runtime.native_launch_profile_control();
20735        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20736            profile_id.clone(),
20737            profile_revision.clone(),
20738            agent("claude"),
20739            [NativeLaunchProfile::new(
20740                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20741                agent("claude"),
20742                TransportKind::Pty,
20743                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20744                Arc::new(EmptyEnvironmentResolver),
20745            )
20746            .unwrap()],
20747            Some(materialization),
20748        )
20749        .unwrap();
20750        let (binding, native_profiles, materialization) = node_profile.into_parts();
20751        for native_profile in native_profiles {
20752            runtime.upsert_native_launch_profile(native_profile).unwrap();
20753        }
20754        let shared = NodeShared::new_with_incarnation(
20755            handle,
20756            "fixture-token".to_owned(),
20757            NodeId::new("node-materialization-test").unwrap(),
20758            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20759            vec![workspace],
20760            vec![agent("claude")],
20761            ProviderRuntimeStatuses::default(),
20762            None,
20763            Vec::new(),
20764            Vec::new(),
20765            SpawnProfileRegistry::default(),
20766            Some(control),
20767            None,
20768            Some(materializer),
20769            None,
20770            Vec::new(),
20771            Vec::new(),
20772            Vec::new(),
20773            Vec::new(),
20774            None,
20775            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20776        );
20777        shared
20778            .environment_profiles
20779            .write()
20780            .unwrap_or_else(|poisoned| poisoned.into_inner())
20781            .insert(binding.id.clone(), binding);
20782        shared
20783            .environment_materialization_profiles
20784            .write()
20785            .unwrap_or_else(|poisoned| poisoned.into_inner())
20786            .insert(profile_id.clone(), materialization.unwrap());
20787        (
20788            shared,
20789            runtime,
20790            ResolvedEnvironmentProfileReceipt {
20791                profile_id,
20792                profile_revision,
20793                network_allowlist: None,
20794                browser_profile_id: None,
20795            },
20796            root,
20797            deny,
20798        )
20799    }
20800
20801    #[cfg(feature = "fixture")]
20802    #[derive(Clone, Copy)]
20803    enum CodexHomeProfile {
20804        Missing,
20805        WrongClass,
20806        Exact,
20807    }
20808
20809    #[cfg(feature = "fixture")]
20810    fn codex_bundle_test_shared(
20811        home_profile: CodexHomeProfile,
20812    ) -> (
20813        NodeShared,
20814        NativeRuntime,
20815        ResolvedEnvironmentProfileReceipt,
20816        NodeBundle,
20817        PathBuf,
20818    ) {
20819        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20820        const CLAUDE_MANIFEST: &[u8] =
20821            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20822        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20823        const DIGEST: &str =
20824            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20825
20826        let (shared, mut runtime, _claude_receipt, root, _deny) =
20827            materialization_test_shared(false);
20828        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20829            CodexHomeProfile::Missing => "codex-home-missing",
20830            CodexHomeProfile::WrongClass => "codex-home-wrong",
20831            CodexHomeProfile::Exact => "codex-home-exact",
20832        })
20833        .unwrap();
20834        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20835            "codex-home-r1",
20836        )
20837        .unwrap();
20838        let materialization = match home_profile {
20839            CodexHomeProfile::Missing => None,
20840            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20841                Some(NodeSessionMaterializationProfile::new(
20842                    Vec::new(),
20843                    vec![NodeSessionPathBinding::new(
20844                        "CODEX_HOME",
20845                        if matches!(home_profile, CodexHomeProfile::Exact) {
20846                            NodeSessionPathClass::ProviderHome
20847                        } else {
20848                            NodeSessionPathClass::Config
20849                        },
20850                    )
20851                    .unwrap()],
20852                    Vec::new(),
20853                )
20854                .unwrap())
20855            }
20856        };
20857        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20858            profile_id.clone(),
20859            profile_revision.clone(),
20860            agent("codex"),
20861            [NativeLaunchProfile::new(
20862                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20863                agent("codex"),
20864                TransportKind::Pty,
20865                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20866                Arc::new(EmptyEnvironmentResolver),
20867            )
20868            .unwrap()],
20869            materialization,
20870        )
20871        .unwrap();
20872        let (binding, native_profiles, materialization) = node_profile.into_parts();
20873        for native_profile in native_profiles {
20874            runtime.upsert_native_launch_profile(native_profile).unwrap();
20875        }
20876        shared
20877            .environment_profiles
20878            .write()
20879            .unwrap_or_else(|poisoned| poisoned.into_inner())
20880            .insert(binding.id.clone(), binding);
20881        if let Some(materialization) = materialization {
20882            shared
20883                .environment_materialization_profiles
20884                .write()
20885                .unwrap_or_else(|poisoned| poisoned.into_inner())
20886                .insert(profile_id.clone(), materialization);
20887        }
20888
20889        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20890        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20891        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20892        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20893        std::fs::write(
20894            bundle_source.join(".claude-plugin/plugin.json"),
20895            CLAUDE_MANIFEST,
20896        )
20897        .unwrap();
20898        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20899        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20900        let bundle = NodeBundle::new(
20901            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20902            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20903            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20904            &bundle_source,
20905        )
20906        .unwrap();
20907        *shared
20908            .bundle_catalog
20909            .write()
20910            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20911            BundleCatalog::new([bundle.clone()]).unwrap();
20912        (
20913            shared,
20914            runtime,
20915            ResolvedEnvironmentProfileReceipt {
20916                profile_id,
20917                profile_revision,
20918                network_allowlist: None,
20919                browser_profile_id: None,
20920            },
20921            bundle,
20922            root,
20923        )
20924    }
20925
20926    #[cfg(feature = "fixture")]
20927    #[test]
20928    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20929        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20930            let (shared, runtime, receipt, bundle, root) =
20931                codex_bundle_test_shared(home_profile);
20932            let error = shared
20933                .bundle_layout(
20934                    &agent("codex"),
20935                    SessionMode::Pty,
20936                    Some(&receipt),
20937                    &bundle,
20938                )
20939                .unwrap_err();
20940            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20941            assert!(shared
20942                .materializations
20943                .lock()
20944                .unwrap_or_else(|poisoned| poisoned.into_inner())
20945                .is_empty());
20946            let materialization_root = root.join("session-environments");
20947            assert!(!std::fs::read_dir(&materialization_root)
20948                .unwrap()
20949                .filter_map(Result::ok)
20950                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20951            drop(shared);
20952            drop(runtime);
20953            let _ = std::fs::remove_dir_all(root);
20954        }
20955    }
20956
20957    #[cfg(feature = "fixture")]
20958    #[test]
20959    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20960        let (shared, runtime, receipt, bundle, root) =
20961            codex_bundle_test_shared(CodexHomeProfile::Exact);
20962        let address = SessionAddress {
20963            workspace_id: WorkspaceId::new("primary").unwrap(),
20964            session: SessionKey {
20965                instance_id: AgentInstanceId(310),
20966                generation: SessionGeneration(1),
20967            },
20968        };
20969        let (overlay, guard) = shared
20970            .prepare_session_materialization(
20971                &address,
20972                &agent("codex"),
20973                SessionMode::Pty,
20974                Some(&receipt),
20975                Some(&bundle.receipt()),
20976                None,
20977                None,
20978            )
20979            .unwrap()
20980            .unwrap();
20981        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20982        let ownership = shared
20983            .materializations
20984            .lock()
20985            .unwrap_or_else(|poisoned| poisoned.into_inner())
20986            .get(guard.id().unwrap())
20987            .unwrap()
20988            .clone();
20989        let (profile, _, _) = shared
20990            .materialization_profile(
20991                &agent("codex"),
20992                SessionMode::Pty,
20993                Some(&receipt),
20994                Some(&bundle.receipt()),
20995                None,
20996            )
20997            .unwrap()
20998            .unwrap();
20999        let environment = shared
21000            .session_environment_materializer
21001            .as_ref()
21002            .unwrap()
21003            .resolve_environment(&ownership, &profile)
21004            .unwrap();
21005        assert!(environment.iter().any(|mutation| {
21006            mutation.key == OsString::from("CODEX_HOME")
21007                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21008        }));
21009        assert_eq!(
21010            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21011                .unwrap(),
21012            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21013        );
21014        assert!(std::fs::read_dir(ownership.bundle_root())
21015            .unwrap()
21016            .next()
21017            .is_none());
21018        drop(guard);
21019        drop(shared);
21020        drop(runtime);
21021        let _ = std::fs::remove_dir_all(root);
21022    }
21023
21024    #[cfg(feature = "fixture")]
21025    #[test]
21026    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21027        let (shared, runtime, receipt, bundle, root) =
21028            codex_bundle_test_shared(CodexHomeProfile::Exact);
21029        let bundle_receipt = bundle.receipt();
21030        let address = SessionAddress {
21031            workspace_id: WorkspaceId::new("primary").unwrap(),
21032            session: SessionKey {
21033                instance_id: AgentInstanceId(311),
21034                generation: SessionGeneration(1),
21035            },
21036        };
21037        let (_overlay, guard) = shared
21038            .prepare_session_materialization(
21039                &address,
21040                &agent("codex"),
21041                SessionMode::Pty,
21042                Some(&receipt),
21043                Some(&bundle_receipt),
21044                None,
21045                None,
21046            )
21047            .unwrap()
21048            .unwrap();
21049        let materialization_id = guard.id().unwrap().clone();
21050        let skill_path = shared
21051            .materializations
21052            .lock()
21053            .unwrap_or_else(|poisoned| poisoned.into_inner())
21054            .get(&materialization_id)
21055            .unwrap()
21056            .provider_home()
21057            .join("skills/review-code/SKILL.md");
21058        let record_id = shared
21059            .bind_spawn_session_with_materialization(
21060                &address,
21061                agent("codex"),
21062                SessionMode::Pty,
21063                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21064                SpawnRecordPolicy::ProviderIdentityOnly,
21065                Some(receipt.clone()),
21066                Some(bundle_receipt.clone()),
21067                None,
21068                Some(materialization_id.clone()),
21069            )
21070            .unwrap()
21071            .unwrap();
21072        guard.retain();
21073        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21074
21075        let error = match shared.resolve_record_materialization(
21076                &record_id,
21077                &agent("codex"),
21078                SessionMode::Pty,
21079                Some(&receipt),
21080                Some(&bundle_receipt),
21081                None,
21082            ) {
21083                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21084                Err(error) => error,
21085            };
21086        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21087        assert_eq!(
21088            shared
21089                .materializations
21090                .lock()
21091                .unwrap_or_else(|poisoned| poisoned.into_inner())
21092                .get(&materialization_id)
21093                .unwrap()
21094                .state(),
21095            MaterializationState::RecoveryRequired,
21096        );
21097        drop(shared);
21098        drop(runtime);
21099        let _ = std::fs::remove_dir_all(root);
21100    }
21101
21102    fn bind_record_owned_materialization_fixture(
21103        shared: &NodeShared,
21104        receipt: &ResolvedEnvironmentProfileReceipt,
21105        instance_id: u64,
21106    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21107        let address = SessionAddress {
21108            workspace_id: WorkspaceId::new("primary").unwrap(),
21109            session: SessionKey {
21110                instance_id: AgentInstanceId(instance_id),
21111                generation: SessionGeneration(1),
21112            },
21113        };
21114        let (overlay, materialization_guard) = shared
21115            .prepare_session_materialization(
21116                &address,
21117                &agent("claude"),
21118                SessionMode::Pty,
21119                Some(receipt),
21120                None,
21121                None,
21122                None,
21123            )
21124            .unwrap()
21125            .unwrap();
21126        let materialization_id = materialization_guard.id().unwrap().clone();
21127        let materialization_root = shared
21128            .materializations
21129            .lock()
21130            .unwrap_or_else(|poisoned| poisoned.into_inner())
21131            .get(&materialization_id)
21132            .unwrap()
21133            .root()
21134            .to_path_buf();
21135        let selection = shared
21136            .select_environment_profile(
21137                address.session.instance_id,
21138                &agent("claude"),
21139                SessionMode::Pty,
21140                Some(receipt),
21141            )
21142            .unwrap()
21143            .unwrap();
21144        assert!(shared
21145            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21146            .unwrap()
21147            .is_none());
21148        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21149        let record_id = shared
21150            .bind_spawn_session_with_materialization(
21151                &address,
21152                agent("claude"),
21153                SessionMode::Pty,
21154                policy,
21155                SpawnRecordPolicy::ProviderIdentityOnly,
21156                Some(receipt.clone()),
21157                None,
21158                None,
21159                Some(materialization_id.clone()),
21160            )
21161            .unwrap()
21162            .unwrap();
21163        selection.retain();
21164        materialization_guard.retain();
21165        (address, record_id, materialization_id, materialization_root)
21166    }
21167
21168    #[test]
21169    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21170        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21171        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21172            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21173        }));
21174        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21175        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21176        let mut spec = spawn_spec_fixture();
21177        spec.expected_profile_revision =
21178            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21179        assert!(matches!(
21180            &spec.overrides.environment_profile_id,
21181            crate::protocol::SpawnOverride::Inherit,
21182        ));
21183        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21184            .requires_child_environment_profile_capability());
21185        assert!(request_requires_child_environment_profile(
21186            &shared,
21187            &NodeRequest::SpawnSpec { spec: spec.clone() },
21188        ));
21189
21190        let address = terminal_address(1);
21191        shared.bind_session_with_policy(
21192            &address,
21193            ProviderRuntimePolicy::raw_pty(),
21194            Some(environment_profile.clone()),
21195        );
21196        assert!(request_requires_child_environment_profile(
21197            &shared,
21198            &NodeRequest::Input {
21199                session: address.clone(),
21200                text: "x".to_owned(),
21201            },
21202        ));
21203
21204        let mut record = record("claude", "profile-record");
21205        record.active_session = Some(address.clone());
21206        record.environment_profile = Some(environment_profile.clone());
21207        let record_id = record.record_id.clone();
21208        shared.insert_record(record.clone()).unwrap();
21209        assert!(request_requires_child_environment_profile(
21210            &shared,
21211            &NodeRequest::ResumeSessionRecord {
21212                record_id,
21213                terminal_size: gate4agent_types::TerminalSize {
21214                    rows: 24,
21215                    columns: 80,
21216                },
21217                initial_prompt: None,
21218            },
21219        ));
21220
21221        let snapshot = snapshot_for_wire(
21222            &shared, false, true, true, true, false, false, false, false, true,
21223        );
21224        assert_eq!(snapshot.session_records.len(), 1);
21225        assert!(snapshot.session_records[0].environment_profile.is_none());
21226        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21227            sequence: 1,
21228            event: NodeEvent::SessionRecordUpserted { record },
21229        });
21230        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21231            panic!("record projection changed the event kind");
21232        };
21233        assert!(record.environment_profile.is_none());
21234
21235        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21236        let mut reply = ResponseEnvelope {
21237            request_id: 1,
21238            result: Ok(NodeResponse::SpawnSpecAccepted {
21239                receipt: resolved.receipt_with_environment(
21240                    shared.incarnation_id,
21241                    address,
21242                    Some(environment_profile),
21243                ),
21244            }),
21245        };
21246        project_response_without_child_environment_profile(&mut reply);
21247        assert_eq!(
21248            reply.result.unwrap_err().code,
21249            NodeFailureCode::UnsupportedCapability,
21250        );
21251    }
21252
21253    #[test]
21254    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21255        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21256        let snapshot = shared.snapshot();
21257        let profiles = snapshot
21258            .launch_inventory
21259            .as_ref()
21260            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21261            .unwrap();
21262        assert_eq!(profiles.len(), 1);
21263        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21264        assert!(snapshot.requires_child_environment_profile_capability());
21265
21266        let mut legacy = snapshot.clone();
21267        clear_snapshot_child_environment_profiles(&mut legacy);
21268        assert!(legacy
21269            .launch_inventory
21270            .as_ref()
21271            .unwrap()
21272            .spawn_profiles
21273            .as_ref()
21274            .unwrap()[0]
21275            .environment_profile
21276            .is_none());
21277        assert!(!legacy.requires_child_environment_profile_capability());
21278
21279        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21280        let bindings = shared
21281            .environment_profiles
21282            .read()
21283            .unwrap_or_else(|poisoned| poisoned.into_inner())
21284            .clone();
21285        let mut provider_mismatch = bindings.clone();
21286        provider_mismatch
21287            .values_mut()
21288            .next()
21289            .unwrap()
21290            .provider = agent("codex");
21291        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21292        let mut mode_mismatch = profile;
21293        mode_mismatch.mode = SessionMode::Inline;
21294        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21295
21296        shared
21297            .environment_profiles
21298            .write()
21299            .unwrap_or_else(|poisoned| poisoned.into_inner())
21300            .clear();
21301        assert!(shared
21302            .snapshot()
21303            .launch_inventory
21304            .unwrap()
21305            .spawn_profiles
21306            .unwrap()
21307            .is_empty());
21308    }
21309
21310    #[test]
21311    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21312        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21313            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21314        }));
21315        let shared = terminal_test_shared();
21316        let bundle = ResolvedBundleReceipt {
21317            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21318            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21319            digest: crate::protocol::SpawnBundleDigest::new(format!(
21320                "sha256:{}",
21321                "0".repeat(64),
21322            ))
21323            .unwrap(),
21324        };
21325        let mut bundled = record("claude", "bundle-record");
21326        bundled.bundle = Some(bundle.clone());
21327        let record_id = bundled.record_id.clone();
21328        shared.insert_record(bundled.clone()).unwrap();
21329        assert!(request_requires_session_bundle(
21330            &shared,
21331            &NodeRequest::ForgetSessionRecord {
21332                record_id: record_id.clone(),
21333            },
21334        ));
21335
21336        let snapshot = snapshot_for_wire(
21337            &shared, false, true, true, true, true, false, false, false, true,
21338        );
21339        assert!(snapshot.session_records[0].bundle.is_none());
21340        let launch = snapshot.launch_inventory.as_ref().unwrap();
21341        assert!(launch.spawn_profiles.is_some());
21342        assert!(launch.bundles.is_none());
21343        let event = project_event_without_session_bundle(NodeEventEnvelope {
21344            sequence: 1,
21345            event: NodeEvent::SessionRecordUpserted { record: bundled },
21346        });
21347        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21348            panic!("record projection changed the event kind");
21349        };
21350        assert!(record.bundle.is_none());
21351
21352        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21353        let mut reply = ResponseEnvelope {
21354            request_id: 1,
21355            result: Ok(NodeResponse::SpawnSpecAccepted {
21356                receipt: resolved.receipt_with_materialization(
21357                    shared.incarnation_id,
21358                    terminal_address(17),
21359                    None,
21360                    Some(bundle),
21361                    None,
21362                ),
21363            }),
21364        };
21365        project_response_without_session_bundle(&mut reply);
21366        assert_eq!(
21367            reply.result.unwrap_err().code,
21368            NodeFailureCode::UnsupportedCapability,
21369        );
21370    }
21371
21372    #[test]
21373    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21374        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21375            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21376        }));
21377        let context_id = SpawnContextId::new("context-review").unwrap();
21378        let context = ResolvedContextPackReceipt {
21379            id: context_id.clone(),
21380            digest: crate::protocol::SpawnContextDigest::new(format!(
21381                "sha256:{}",
21382                "c".repeat(64),
21383            ))
21384            .unwrap(),
21385            lineage: ContextPackLineageReceipt {
21386                source_node_id: NodeId::new("node-source").unwrap(),
21387                source_session: terminal_address(1),
21388                source_provider: agent("kimi"),
21389            },
21390            source_message_count: 2,
21391            retained_message_count: 2,
21392            byte_len: 256,
21393            truncated: false,
21394        };
21395        assert!(context.is_valid());
21396        let mut bound_record = record("codex", "context-record");
21397        bound_record.context_id = Some(context_id);
21398        bound_record.context = Some(context.clone());
21399        let session = gate4agent_types::SessionSnapshot {
21400            instance_id: AgentInstanceId(91),
21401            agent_id: agent("kimi"),
21402            transport: TransportKind::Pty,
21403            generation: SessionGeneration(3),
21404            status: SessionStatus::Running,
21405            pending_operation: None,
21406            pending_input: None,
21407            process_id: Some(9001),
21408            terminal_size: None,
21409            terminal_frame: None,
21410            terminal_stale: None,
21411            session_options: None,
21412            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21413            history: gate4agent_types::HistorySnapshot {
21414                pending: None,
21415                candidates: vec![HistoryCandidateSummary {
21416                    id: "candidate-opaque".to_owned(),
21417                    session_id_hint: "session-hint".to_owned(),
21418                    modified_at_unix_ms: Some(1),
21419                }],
21420                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21421                loaded: Some(HistorySessionRecord {
21422                    session_id: "private-vendor-session".to_owned(),
21423                    title: Some("private title".to_owned()),
21424                    cwd: Some(r"C:\private\history-root".to_owned()),
21425                    model: Some("private-model".to_owned()),
21426                    message_count: 2,
21427                    completed_turn_count: None,
21428                    total_tokens: 19,
21429                    messages: vec![
21430                        gate4agent_types::HistoryMessageRecord {
21431                            role: gate4agent_types::HistoryMessageRole::User,
21432                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21433                        },
21434                        gate4agent_types::HistoryMessageRecord {
21435                            role: gate4agent_types::HistoryMessageRole::Assistant,
21436                            text: "private answer".to_owned(),
21437                        },
21438                    ],
21439                }),
21440                last_error: None,
21441            },
21442            resume: gate4agent_types::ResumeSnapshot::default(),
21443            foreground: gate4agent_types::ForegroundSnapshot::default(),
21444            provider: gate4agent_types::ProviderSnapshot::default(),
21445            screen_state: gate4agent_types::PtyScreenState::default(),
21446        };
21447        let original = NodeSnapshot {
21448            node_id: NodeId::new("node-terminal-test").unwrap(),
21449            enabled_providers: vec![agent("kimi"), agent("codex")],
21450            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21451            workspaces: vec![WorkspaceSnapshot {
21452                workspace_id: WorkspaceId::new("primary").unwrap(),
21453                canonical_root: opaque_windows_path(
21454                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21455                ),
21456                sessions: vec![session],
21457                worktree_service_mode: None,
21458                managed_worktree_profiles: None,
21459            }],
21460            session_records: vec![bound_record],
21461            managed_worktrees: Vec::new(),
21462            launch_inventory: None,
21463            agent_progress: Vec::new(),
21464        };
21465
21466        let mut negotiated = original.clone();
21467        project_snapshot_history_for_wire(&mut negotiated, true);
21468        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21469        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21470        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21471        assert!(!serde_json::to_string(&negotiated)
21472            .unwrap()
21473            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21474
21475        let mut legacy = original;
21476        project_snapshot_history_for_wire(&mut legacy, false);
21477        clear_snapshot_context_packs(&mut legacy);
21478        assert_eq!(
21479            legacy.workspaces[0].sessions[0].history,
21480            gate4agent_types::HistorySnapshot::default(),
21481        );
21482        assert!(legacy.session_records[0].context_id.is_none());
21483        assert!(legacy.session_records[0].context.is_none());
21484        let encoded = serde_json::to_string(&legacy).unwrap();
21485        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21486        assert!(!encoded.contains("context-review"));
21487
21488        let mut reply = ResponseEnvelope {
21489            request_id: 7,
21490            result: Ok(NodeResponse::ContextPackExported { context }),
21491        };
21492        project_response_without_context_pack(&mut reply);
21493        assert_eq!(
21494            reply.result.unwrap_err().code,
21495            NodeFailureCode::UnsupportedCapability,
21496        );
21497    }
21498
21499    #[test]
21500    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21501        let shared = terminal_test_shared();
21502        let history = HistorySessionRecord {
21503            session_id: "fixture-history".to_owned(),
21504            title: None,
21505            cwd: None,
21506            model: None,
21507            message_count: 1,
21508            completed_turn_count: None,
21509            total_tokens: 0,
21510            messages: vec![gate4agent_types::HistoryMessageRecord {
21511                role: gate4agent_types::HistoryMessageRole::User,
21512                text: "bounded handoff".to_owned(),
21513            }],
21514        };
21515        let pack = NodeContextPack::export(
21516            ContextPackLineageReceipt {
21517                source_node_id: shared.node_id.clone(),
21518                source_session: terminal_address(1),
21519                source_provider: agent("claude"),
21520            },
21521            &history,
21522        )
21523        .unwrap();
21524        let context = shared
21525            .context_catalog
21526            .write()
21527            .unwrap_or_else(|poisoned| poisoned.into_inner())
21528            .insert(pack)
21529            .unwrap();
21530        let mut retained = record("codex", "context-retained-record");
21531        retained.context_id = Some(context.id.clone());
21532        retained.context = Some(context.clone());
21533        let record_id = retained.record_id.clone();
21534        shared.insert_record(retained).unwrap();
21535        assert_eq!(
21536            shared.forget_context_pack(&context.id).unwrap_err().code,
21537            NodeFailureCode::ContextPackBusy,
21538        );
21539        assert!(shared
21540            .context_catalog
21541            .read()
21542            .unwrap_or_else(|poisoned| poisoned.into_inner())
21543            .get(&context.id)
21544            .is_some());
21545        shared
21546            .session_records
21547            .lock()
21548            .unwrap_or_else(|poisoned| poisoned.into_inner())
21549            .records
21550            .remove(&record_id);
21551
21552        let mut spec = spawn_spec_fixture();
21553        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21554            value: context.id.clone(),
21555        };
21556        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21557        let receipt = resolved.receipt_with_materialization(
21558            shared.incarnation_id,
21559            terminal_address(2),
21560            None,
21561            None,
21562            Some(context.clone()),
21563        );
21564        assert!(receipt.context_binding_is_valid());
21565        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21566
21567        shared.forget_context_pack(&context.id).unwrap();
21568        assert!(shared
21569            .context_catalog
21570            .read()
21571            .unwrap_or_else(|poisoned| poisoned.into_inner())
21572            .get(&context.id)
21573            .is_none());
21574        assert!(shared
21575            .replay_spawn_spec(&spec, Instant::now())
21576            .unwrap()
21577            .is_none());
21578    }
21579
21580    #[test]
21581    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21582        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21583        let address = terminal_address(1);
21584        let selection = shared
21585            .select_environment_profile(
21586                address.session.instance_id,
21587                &agent("claude"),
21588                SessionMode::Pty,
21589                Some(&environment_profile),
21590            )
21591            .unwrap()
21592            .unwrap();
21593        shared.bind_session_with_policy(
21594            &address,
21595            ProviderRuntimePolicy::raw_pty(),
21596            Some(environment_profile.clone()),
21597        );
21598        selection.retain();
21599        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21600        assert!(matches!(
21601            runtime.remove_native_launch_profile(&native_id),
21602            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21603        ));
21604        assert!(shared.remove_binding(&address).is_some());
21605        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21606
21607        let mismatched = ResolvedEnvironmentProfileReceipt {
21608            profile_id: environment_profile.profile_id,
21609            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21610                "local-claude-r2",
21611            )
21612            .unwrap(),
21613            network_allowlist: None,
21614            browser_profile_id: None,
21615        };
21616        let error = match shared.select_environment_profile(
21617                AgentInstanceId(42),
21618                &agent("claude"),
21619                SessionMode::Pty,
21620                Some(&mismatched),
21621            ) {
21622            Ok(_) => panic!("mismatched environment profile revision was selected"),
21623            Err(error) => error,
21624        };
21625        assert_eq!(
21626            error.code,
21627            NodeFailureCode::EnvironmentProfileBindingMismatch,
21628        );
21629        assert!(!shared
21630            .session_bindings
21631            .lock()
21632            .unwrap_or_else(|poisoned| poisoned.into_inner())
21633            .contains_key(&AgentInstanceId(42)));
21634    }
21635
21636    #[test]
21637    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21638        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21639        let address = terminal_address(1);
21640        let selection = shared
21641            .select_environment_profile(
21642                address.session.instance_id,
21643                &agent("claude"),
21644                SessionMode::Pty,
21645                Some(&environment_profile),
21646            )
21647            .unwrap()
21648            .unwrap();
21649        let overlay = NativeInstanceLaunchOverlay::new(
21650            agent("claude"),
21651            TransportKind::Pty,
21652            vec![EnvMutation {
21653                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21654                value: Some(OsString::from("fixture-secret")),
21655            }],
21656            vec![
21657                OsString::from("--plugin-dir"),
21658                std::env::current_dir().unwrap().into_os_string(),
21659            ],
21660        )
21661        .unwrap();
21662        let instance_overlay = shared
21663            .install_prepared_launch_overlay(
21664                address.session.instance_id,
21665                PreparedNativeLaunchOverlay::Instance(overlay),
21666            )
21667            .unwrap()
21668            .unwrap();
21669        let bundle = ResolvedBundleReceipt {
21670            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21671            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21672            digest: crate::protocol::SpawnBundleDigest::new(format!(
21673                "sha256:{}",
21674                "0".repeat(64),
21675            ))
21676            .unwrap(),
21677        };
21678        shared.bind_session_with_materialization(
21679            &address,
21680            ProviderRuntimePolicy::raw_pty(),
21681            Some(environment_profile),
21682            Some(bundle),
21683            None,
21684            None,
21685        );
21686        selection.retain();
21687        instance_overlay.retain();
21688
21689        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21690        assert!(matches!(
21691            runtime.remove_native_launch_profile(&native_id),
21692            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21693        ));
21694        assert!(shared.remove_binding(&address).is_some());
21695        assert!(!shared
21696            .native_launch_profile_control
21697            .as_ref()
21698            .unwrap()
21699            .clear_native_instance_launch_overlay(address.session.instance_id));
21700        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21701    }
21702
21703    #[test]
21704    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21705        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21706        let address = SessionAddress {
21707            workspace_id: WorkspaceId::new("primary").unwrap(),
21708            session: SessionKey {
21709                instance_id: AgentInstanceId(201),
21710                generation: SessionGeneration(1),
21711            },
21712        };
21713        let error = match shared.prepare_session_materialization(
21714            &address,
21715            &agent("claude"),
21716            SessionMode::Pty,
21717            Some(&receipt),
21718            None,
21719            None,
21720            None,
21721        ) {
21722            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21723            Err(error) => error,
21724        };
21725        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21726        assert!(shared.handle.snapshot().sessions.is_empty());
21727        assert!(shared
21728            .session_bindings
21729            .lock()
21730            .unwrap_or_else(|poisoned| poisoned.into_inner())
21731            .is_empty());
21732        assert!(shared
21733            .materializations
21734            .lock()
21735            .unwrap_or_else(|poisoned| poisoned.into_inner())
21736            .is_empty());
21737        drop(shared);
21738        drop(runtime);
21739        std::fs::remove_dir_all(root).unwrap();
21740    }
21741
21742    #[test]
21743    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21744        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21745        let address = SessionAddress {
21746            workspace_id: WorkspaceId::new("primary").unwrap(),
21747            session: SessionKey {
21748                instance_id: AgentInstanceId(202),
21749                generation: SessionGeneration(1),
21750            },
21751        };
21752        let (overlay, materialization_guard) = shared
21753            .prepare_session_materialization(
21754                &address,
21755                &agent("claude"),
21756                SessionMode::Pty,
21757                Some(&receipt),
21758                None,
21759                None,
21760                None,
21761            )
21762            .unwrap()
21763            .unwrap();
21764        let materialization_id = materialization_guard.id().unwrap().clone();
21765        let materialization_root = shared
21766            .materializations
21767            .lock()
21768            .unwrap_or_else(|poisoned| poisoned.into_inner())
21769            .get(&materialization_id)
21770            .unwrap()
21771            .root()
21772            .to_path_buf();
21773        let selection = shared
21774            .select_environment_profile(
21775                address.session.instance_id,
21776                &agent("claude"),
21777                SessionMode::Pty,
21778                Some(&receipt),
21779            )
21780            .unwrap()
21781            .unwrap();
21782        assert!(shared
21783            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21784            .unwrap()
21785            .is_none());
21786        shared.bind_session_with_materialization(
21787            &address,
21788            ProviderRuntimePolicy::raw_pty(),
21789            Some(receipt),
21790            None,
21791            None,
21792            Some(materialization_id),
21793        );
21794        selection.retain();
21795        materialization_guard.retain();
21796
21797        assert!(materialization_root.is_dir());
21798        assert!(shared
21799            .session_bindings
21800            .lock()
21801            .unwrap_or_else(|poisoned| poisoned.into_inner())
21802            .contains_key(&address.session.instance_id));
21803
21804        let removed = shared.remove_binding(&address).unwrap();
21805        shared
21806            .cleanup_session_owned_materialization(&address, &removed)
21807            .unwrap();
21808        assert!(!materialization_root.exists());
21809        assert!(shared
21810            .materializations
21811            .lock()
21812            .unwrap_or_else(|poisoned| poisoned.into_inner())
21813            .is_empty());
21814        drop(shared);
21815        drop(runtime);
21816        std::fs::remove_dir_all(root).unwrap();
21817    }
21818
21819    #[tokio::test]
21820    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21821        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21822        let address = SessionAddress {
21823            workspace_id: WorkspaceId::new("primary").unwrap(),
21824            session: SessionKey {
21825                instance_id: AgentInstanceId(203),
21826                generation: SessionGeneration(1),
21827            },
21828        };
21829        let (overlay, materialization_guard) = shared
21830            .prepare_session_materialization(
21831                &address,
21832                &agent("claude"),
21833                SessionMode::Pty,
21834                Some(&receipt),
21835                None,
21836                None,
21837                None,
21838            )
21839            .unwrap()
21840            .unwrap();
21841        let materialization_id = materialization_guard.id().unwrap().clone();
21842        let materialization_root = shared
21843            .materializations
21844            .lock()
21845            .unwrap_or_else(|poisoned| poisoned.into_inner())
21846            .get(&materialization_id)
21847            .unwrap()
21848            .root()
21849            .to_path_buf();
21850        let selection = shared
21851            .select_environment_profile(
21852                address.session.instance_id,
21853                &agent("claude"),
21854                SessionMode::Pty,
21855                Some(&receipt),
21856            )
21857            .unwrap()
21858            .unwrap();
21859        assert!(shared
21860            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21861            .unwrap()
21862            .is_none());
21863        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21864        let record_id = shared
21865            .bind_spawn_session_with_materialization(
21866                &address,
21867                agent("claude"),
21868                SessionMode::Pty,
21869                policy,
21870                SpawnRecordPolicy::ProviderIdentityOnly,
21871                Some(receipt.clone()),
21872                None,
21873                None,
21874                Some(materialization_id.clone()),
21875            )
21876            .unwrap()
21877            .unwrap();
21878        selection.retain();
21879        materialization_guard.retain();
21880        assert_eq!(
21881            shared
21882                .materializations
21883                .lock()
21884                .unwrap_or_else(|poisoned| poisoned.into_inner())
21885                .get(&materialization_id)
21886                .unwrap()
21887                .owner(),
21888            &MaterializationOwner::Record {
21889                record_id: record_id.clone(),
21890            },
21891        );
21892
21893        let removed = shared.remove_binding(&address).unwrap();
21894        shared
21895            .cleanup_session_owned_materialization(&address, &removed)
21896            .unwrap();
21897        assert!(materialization_root.is_dir());
21898        assert!(shared
21899            .resolve_record_materialization(
21900                &record_id,
21901                &agent("claude"),
21902                SessionMode::Pty,
21903                Some(&receipt),
21904                None,
21905                None,
21906            )
21907            .unwrap()
21908            .is_some());
21909        {
21910            let mut records = shared
21911                .session_records
21912                .lock()
21913                .unwrap_or_else(|poisoned| poisoned.into_inner());
21914            let record = records.records.get_mut(&record_id).unwrap();
21915            record.active_session = None;
21916            record.state = ManagedSessionState::Unavailable;
21917        }
21918        shared.forget_session_record(&record_id).await.unwrap();
21919        assert!(!materialization_root.exists());
21920        assert!(shared
21921            .materializations
21922            .lock()
21923            .unwrap_or_else(|poisoned| poisoned.into_inner())
21924            .is_empty());
21925        drop(shared);
21926        drop(runtime);
21927        std::fs::remove_dir_all(root).unwrap();
21928    }
21929
21930    #[test]
21931    fn record_materialization_revalidation_failure_persists_recovery_required() {
21932        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21933        let state_path = root.join("node-state-v6.json");
21934        shared.state_path = Some(state_path.clone());
21935        let (_address, _record_id, materialization_id, materialization_root) =
21936            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21937        std::fs::write(
21938            materialization_root.join(".gate4agent-materialization-owner"),
21939            b"tampered",
21940        )
21941        .unwrap();
21942
21943        let error = match shared.resolve_record_materialization(
21944            &_record_id,
21945            &agent("claude"),
21946            SessionMode::Pty,
21947            Some(&receipt),
21948            None,
21949            None,
21950        ) {
21951            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21952            Err(error) => error,
21953        };
21954        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21955        assert_eq!(
21956            shared
21957                .materializations
21958                .lock()
21959                .unwrap_or_else(|poisoned| poisoned.into_inner())
21960                .get(&materialization_id)
21961                .unwrap()
21962                .state(),
21963            MaterializationState::RecoveryRequired,
21964        );
21965        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21966        assert_eq!(loaded.materializations.len(), 1);
21967        assert_eq!(
21968            loaded.materializations[0].state(),
21969            MaterializationState::RecoveryRequired,
21970        );
21971        drop(shared);
21972        drop(runtime);
21973        std::fs::remove_dir_all(root).unwrap();
21974    }
21975
21976    #[test]
21977    fn transient_record_materialization_resolution_failure_remains_ready() {
21978        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21979        let (_address, record_id, materialization_id, _materialization_root) =
21980            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21981        deny.store(true, Ordering::Release);
21982
21983        let error = match shared.resolve_record_materialization(
21984            &record_id,
21985            &agent("claude"),
21986            SessionMode::Pty,
21987            Some(&receipt),
21988            None,
21989            None,
21990        ) {
21991            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21992            Err(error) => error,
21993        };
21994        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21995        assert_eq!(
21996            shared
21997                .materializations
21998                .lock()
21999                .unwrap_or_else(|poisoned| poisoned.into_inner())
22000                .get(&materialization_id)
22001                .unwrap()
22002                .state(),
22003            MaterializationState::Ready,
22004        );
22005        drop(shared);
22006        drop(runtime);
22007        std::fs::remove_dir_all(root).unwrap();
22008    }
22009
22010    #[test]
22011    fn startup_missing_record_materialization_is_not_resumable() {
22012        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22013        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22014        shared
22015            .insert_record(ManagedSessionRecord {
22016                record_id: record_id.clone(),
22017                display_name: "missing materialization".to_owned(),
22018                provider: agent("claude"),
22019                mode: SessionMode::Pty,
22020                state: ManagedSessionState::Dormant,
22021                workspace_id: WorkspaceId::new("primary").unwrap(),
22022                canonical_root: opaque_windows_path(
22023                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22024                ),
22025                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22026                    key: gate4agent_types::ProviderSessionKey::SessionId,
22027                    id: "provider-session-missing-materialization".to_owned(),
22028                    transcript_path: None,
22029                }),
22030                active_session: None,
22031                environment_profile: Some(receipt),
22032                bundle: None,
22033                context_id: None,
22034                context: None,
22035                exported_context: None,
22036                task_binding: None,
22037                created_at_unix_ms: 1,
22038                updated_at_unix_ms: 1,
22039                last_error: None,
22040            })
22041            .unwrap();
22042
22043        shared.reconcile_materializations();
22044        let record = shared.record(&record_id).unwrap();
22045        assert_eq!(record.state, ManagedSessionState::Unavailable);
22046        assert!(record.provider_session.is_none());
22047        assert_eq!(
22048            record.last_error.as_deref(),
22049            Some("environment-profile-unavailable"),
22050        );
22051        drop(shared);
22052        drop(runtime);
22053        std::fs::remove_dir_all(root).unwrap();
22054    }
22055
22056    #[tokio::test]
22057    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22058        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22059        let lease = install_managed_lease(&shared);
22060        let address = SessionAddress {
22061            workspace_id: WorkspaceId::new("primary").unwrap(),
22062            session: SessionKey {
22063                instance_id: AgentInstanceId(204),
22064                generation: SessionGeneration(1),
22065            },
22066        };
22067        let (overlay, materialization_guard) = shared
22068            .prepare_session_materialization(
22069                &address,
22070                &agent("claude"),
22071                SessionMode::Pty,
22072                Some(&receipt),
22073                None,
22074                None,
22075                Some(lease.lease_id.clone()),
22076            )
22077            .unwrap()
22078            .unwrap();
22079        let materialization_id = materialization_guard.id().unwrap().clone();
22080        let materialization_root = shared
22081            .materializations
22082            .lock()
22083            .unwrap_or_else(|poisoned| poisoned.into_inner())
22084            .get(&materialization_id)
22085            .unwrap()
22086            .root()
22087            .to_path_buf();
22088        let selection = shared
22089            .select_environment_profile(
22090                address.session.instance_id,
22091                &agent("claude"),
22092                SessionMode::Pty,
22093                Some(&receipt),
22094            )
22095            .unwrap()
22096            .unwrap();
22097        assert!(shared
22098            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22099            .unwrap()
22100            .is_none());
22101        shared.bind_session_with_materialization(
22102            &address,
22103            ProviderRuntimePolicy::raw_pty(),
22104            Some(receipt),
22105            None,
22106            None,
22107            Some(materialization_id),
22108        );
22109        selection.retain();
22110        materialization_guard.retain();
22111
22112        let blocked = shared
22113            .cleanup_managed_worktree(&lease.lease_id, false)
22114            .await
22115            .unwrap_err();
22116        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22117        assert!(materialization_root.is_dir());
22118        let removed = shared.remove_binding(&address).unwrap();
22119        shared
22120            .cleanup_session_owned_materialization(&address, &removed)
22121            .unwrap();
22122        assert!(!materialization_root.exists());
22123        drop(shared);
22124        drop(runtime);
22125        std::fs::remove_dir_all(root).unwrap();
22126    }
22127
22128    #[tokio::test]
22129    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22130        let git_root = temporary_workspace_root("raw-managed-inventory");
22131        let source = git_root.join("source");
22132        let allocation = git_root.join("allocation");
22133        std::fs::create_dir_all(&source).unwrap();
22134        std::fs::create_dir_all(&allocation).unwrap();
22135        run_test_git(&source, &["init"]);
22136        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22137        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22138        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22139        run_test_git(&source, &["add", "seed.txt"]);
22140        run_test_git(&source, &["commit", "-m", "seed"]);
22141        let profile = ManagedWorktreeProfile::new(
22142            WorktreeProfileId::new("default").unwrap(),
22143            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22144            &allocation,
22145            "gate4agent",
22146            "HEAD",
22147            ManagedWorktreeRetention::RemoveWhenReleased,
22148        )
22149        .unwrap();
22150        let workspace = WorkspaceConfig::new(
22151            WorkspaceId::new("primary").unwrap(),
22152            &source,
22153        )
22154        .unwrap()
22155        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22156        .with_managed_worktree_profile(profile.clone())
22157        .unwrap();
22158        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22159            materialization_test_shared_with_workspace(false, workspace);
22160        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22161        let source_text = source.to_string_lossy().into_owned();
22162        let target_text = target.to_string_lossy().into_owned();
22163        let created = create_git_worktree(
22164            &source_text,
22165            &target_text,
22166            "gate4agent/mw-raw-inventory",
22167            Some("HEAD"),
22168        )
22169        .await
22170        .unwrap();
22171        let lease = ManagedWorktreeLeaseRecord {
22172            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22173            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22174            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22175            profile_id: profile.profile_id().clone(),
22176            profile_revision: profile.revision().clone(),
22177            target_root: created.path.clone(),
22178            branch: "gate4agent/mw-raw-inventory".to_owned(),
22179            base_commit: created.head.clone(),
22180            expected_head: Some(created.head.clone()),
22181            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22182            state: ManagedWorktreeLeaseState::Ready,
22183            session_holders: Vec::new(),
22184            record_holders: Vec::new(),
22185            cleanup_failure: None,
22186            created_at_unix_ms: 1,
22187            updated_at_unix_ms: 1,
22188        };
22189        shared
22190            .workspaces
22191            .write()
22192            .unwrap_or_else(|poisoned| poisoned.into_inner())
22193            .insert(lease.workspace_id.clone(), created.path.clone());
22194        *shared
22195            .managed_worktrees
22196            .lock()
22197            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22198            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22199        let address = SessionAddress {
22200            workspace_id: lease.workspace_id.clone(),
22201            session: SessionKey {
22202                instance_id: AgentInstanceId(205),
22203                generation: SessionGeneration(1),
22204            },
22205        };
22206        let (overlay, materialization_guard) = shared
22207            .prepare_session_materialization(
22208                &address,
22209                &agent("claude"),
22210                SessionMode::Pty,
22211                Some(&receipt),
22212                None,
22213                None,
22214                Some(lease.lease_id.clone()),
22215            )
22216            .unwrap()
22217            .unwrap();
22218        let materialization_id = materialization_guard.id().unwrap().clone();
22219        let materialization_root = shared
22220            .materializations
22221            .lock()
22222            .unwrap_or_else(|poisoned| poisoned.into_inner())
22223            .get(&materialization_id)
22224            .unwrap()
22225            .root()
22226            .to_path_buf();
22227        let selection = shared
22228            .select_environment_profile(
22229                address.session.instance_id,
22230                &agent("claude"),
22231                SessionMode::Pty,
22232                Some(&receipt),
22233            )
22234            .unwrap()
22235            .unwrap();
22236        assert!(shared
22237            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22238            .unwrap()
22239            .is_none());
22240        let record_id = shared
22241            .bind_spawn_session_with_materialization(
22242                &address,
22243                agent("claude"),
22244                SessionMode::Pty,
22245                ProviderRuntimePolicy::raw_pty(),
22246                SpawnRecordPolicy::Always,
22247                Some(receipt.clone()),
22248                None,
22249                None,
22250                Some(materialization_id.clone()),
22251            )
22252            .unwrap()
22253            .unwrap();
22254        selection.retain();
22255        materialization_guard.retain();
22256        let record = shared.record(&record_id).unwrap();
22257        assert_eq!(record.state, ManagedSessionState::Live);
22258        assert!(record.provider_session.is_none());
22259        assert!(record.environment_profile.is_none());
22260        assert!(record.bundle.is_none());
22261        assert!(record.context_id.is_none());
22262        assert!(record.context.is_none());
22263        assert_eq!(record.active_session.as_ref(), Some(&address));
22264        assert_eq!(
22265            shared
22266                .materializations
22267                .lock()
22268                .unwrap_or_else(|poisoned| poisoned.into_inner())
22269                .get(&materialization_id)
22270                .unwrap()
22271                .owner(),
22272            &MaterializationOwner::Session {
22273                incarnation_id: shared.incarnation_id,
22274                instance_id: address.session.instance_id,
22275                generation: address.session.generation,
22276            },
22277        );
22278        {
22279            let mut bindings = shared
22280                .session_bindings
22281                .lock()
22282                .unwrap_or_else(|poisoned| poisoned.into_inner());
22283            bindings
22284                .get_mut(&address.session.instance_id)
22285                .unwrap()
22286                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22287        }
22288        let lease_snapshot = shared
22289            .managed_worktrees
22290            .lock()
22291            .unwrap_or_else(|poisoned| poisoned.into_inner())
22292            .bind_session(
22293                &lease.lease_id,
22294                ManagedWorktreeSessionHolder {
22295                    incarnation_id: shared.incarnation_id,
22296                    instance_id: address.session.instance_id,
22297                    generation: address.session.generation,
22298                },
22299                None,
22300                unix_time_ms(),
22301            )
22302            .unwrap();
22303        assert_eq!(lease_snapshot.active_session_count, 1);
22304        assert_eq!(lease_snapshot.managed_record_count, 0);
22305        shared.persist_state().unwrap();
22306        assert!(shared
22307            .managed_worktrees
22308            .lock()
22309            .unwrap_or_else(|poisoned| poisoned.into_inner())
22310            .get(&lease.lease_id)
22311            .unwrap()
22312            .record_holders
22313            .is_empty());
22314        shared
22315            .handle
22316            .dispatch(shared.prepare_command(ControlCommand::Register {
22317                instance_id: address.session.instance_id,
22318                agent_id: agent("claude"),
22319                transport: TransportKind::Pty,
22320            }))
22321            .unwrap();
22322        runtime.tick().await;
22323        shared.reconcile_managed_record(
22324            &record_id,
22325            &address,
22326            &ControlEvent {
22327                sequence: 1,
22328                command_id: None,
22329                instance_id: address.session.instance_id,
22330                generation: address.session.generation,
22331                event: ControlEventKind::Removed,
22332            },
22333            false,
22334            false,
22335        );
22336        let remove = shared.remove_session(&address);
22337        let drive_runtime = async {
22338            runtime.tick().await;
22339        };
22340        let (removed, ()) = tokio::join!(remove, drive_runtime);
22341        removed.unwrap();
22342
22343        assert!(!materialization_root.exists());
22344        assert!(!Path::new(&created.path).exists());
22345        assert_eq!(
22346            shared
22347                .managed_worktrees
22348                .lock()
22349                .unwrap_or_else(|poisoned| poisoned.into_inner())
22350                .get(&lease.lease_id)
22351                .unwrap()
22352                .state,
22353            ManagedWorktreeLeaseState::Removed,
22354        );
22355        let renamed = shared
22356            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22357            .unwrap();
22358        assert_eq!(renamed.record_id, record_id);
22359        assert_eq!(renamed.display_name, "raw managed inventory");
22360        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22361        assert!(renamed.active_session.is_none());
22362
22363        drop(shared);
22364        drop(runtime);
22365        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22366        std::fs::remove_dir_all(git_root).unwrap();
22367    }
22368
22369    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22370        let root = std::env::current_dir().unwrap()
22371            .join("managed-test-target")
22372            .to_string_lossy()
22373            .into_owned();
22374        let lease = ManagedWorktreeLeaseRecord {
22375            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22376            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22377            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22378            profile_id: WorktreeProfileId::new("default").unwrap(),
22379            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22380            target_root: root.clone(),
22381            branch: "gate4agent/mw-test".to_owned(),
22382            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22383            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22384            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22385            state: ManagedWorktreeLeaseState::Ready,
22386            session_holders: Vec::new(),
22387            record_holders: Vec::new(),
22388            cleanup_failure: None,
22389            created_at_unix_ms: 1,
22390            updated_at_unix_ms: 1,
22391        };
22392        shared.workspaces.write()
22393            .unwrap_or_else(|poisoned| poisoned.into_inner())
22394            .insert(lease.workspace_id.clone(), root);
22395        *shared.managed_worktrees.lock()
22396            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22397            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22398        lease
22399    }
22400
22401    #[cfg(feature = "fixture")]
22402    #[test]
22403    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22404        let mut shared = terminal_test_shared();
22405        shared.fixture_semantic_hook_policy = true;
22406        let lease = install_managed_lease(&shared);
22407        let address = SessionAddress {
22408            workspace_id: lease.workspace_id.clone(),
22409            session: SessionKey {
22410                instance_id: AgentInstanceId(206),
22411                generation: SessionGeneration(1),
22412            },
22413        };
22414        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22415        let effective_runtime_policy =
22416            shared.effective_spawn_runtime_policy(pre_admission_policy);
22417        assert_ne!(effective_runtime_policy, pre_admission_policy);
22418        assert!(effective_runtime_policy.provider_session_identity);
22419
22420        let record_id = shared
22421            .bind_spawn_session(
22422                &address,
22423                agent("claude"),
22424                SessionMode::Pty,
22425                effective_runtime_policy,
22426                SpawnRecordPolicy::Always,
22427                None,
22428            )
22429            .unwrap()
22430            .unwrap();
22431        assert_eq!(
22432            shared.record(&record_id).unwrap().state,
22433            ManagedSessionState::IdentityPending,
22434        );
22435        let (record_holder, raw_inventory_record_id) = {
22436            let mut bindings = shared
22437                .session_bindings
22438                .lock()
22439                .unwrap_or_else(|poisoned| poisoned.into_inner());
22440            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22441            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22442            managed_spawn_record_ownership(binding, effective_runtime_policy)
22443        };
22444        assert_eq!(record_holder.as_ref(), Some(&record_id));
22445        assert!(raw_inventory_record_id.is_none());
22446        let snapshot = shared
22447            .managed_worktrees
22448            .lock()
22449            .unwrap_or_else(|poisoned| poisoned.into_inner())
22450            .bind_session(
22451                &lease.lease_id,
22452                ManagedWorktreeSessionHolder {
22453                    incarnation_id: shared.incarnation_id,
22454                    instance_id: address.session.instance_id,
22455                    generation: address.session.generation,
22456                },
22457                record_holder,
22458                unix_time_ms(),
22459            )
22460            .unwrap();
22461        assert_eq!(snapshot.managed_record_count, 1);
22462        assert_eq!(
22463            shared
22464                .managed_worktrees
22465                .lock()
22466                .unwrap_or_else(|poisoned| poisoned.into_inner())
22467                .get(&lease.lease_id)
22468                .unwrap()
22469                .record_holders,
22470            vec![record_id.clone()],
22471        );
22472
22473        let identity = ProviderSessionIdentity {
22474            key: ProviderSessionKey::SessionId,
22475            id: "monitoring-fixture-managed-session".to_owned(),
22476            transcript_path: None,
22477        };
22478        shared.publish_control(ControlEvent {
22479            sequence: 1,
22480            command_id: None,
22481            instance_id: address.session.instance_id,
22482            generation: address.session.generation,
22483            event: ControlEventKind::ProviderEvent {
22484                sequence: 1,
22485                source: gate4agent_types::ProviderSource {
22486                    family: AdapterFamily::Hook,
22487                    binding: AdapterBinding::new(
22488                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22489                        "fixture/v1",
22490                        gate4agent_types::AdapterVerification::SyntheticFixture,
22491                    )
22492                    .unwrap(),
22493                },
22494                source_sequence: 1,
22495                event: ProviderEvent::SessionIdentityObserved {
22496                    identity: identity.clone(),
22497                },
22498            },
22499        });
22500
22501        let live = shared.record(&record_id).unwrap();
22502        assert_eq!(live.state, ManagedSessionState::Live);
22503        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22504        assert_eq!(live.active_session.as_ref(), Some(&address));
22505        assert_eq!(
22506            shared
22507                .managed_worktrees
22508                .lock()
22509                .unwrap_or_else(|poisoned| poisoned.into_inner())
22510                .get(&lease.lease_id)
22511                .unwrap()
22512                .record_holders,
22513            vec![record_id],
22514        );
22515    }
22516
22517    fn managed_v2_test_shared(
22518        allocation_root: &Path,
22519        state_path: Option<PathBuf>,
22520        profile_revision: &str,
22521    ) -> NodeShared {
22522        let profile = ManagedWorktreeProfile::new(
22523            WorktreeProfileId::new("default").unwrap(),
22524            WorktreeProfileRevision::new(profile_revision).unwrap(),
22525            allocation_root,
22526            "gate4agent",
22527            "HEAD",
22528            ManagedWorktreeRetention::RemoveWhenReleased,
22529        )
22530        .unwrap();
22531        let workspace = WorkspaceConfig::new(
22532            WorkspaceId::new("primary").unwrap(),
22533            std::env::current_dir().unwrap(),
22534        )
22535        .unwrap()
22536        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22537        .with_managed_worktree_profile(profile)
22538        .unwrap();
22539        let catalog = active_registry().unwrap();
22540        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22541        let mut shared = NodeShared::new(
22542            handle,
22543            "fixture-token".to_owned(),
22544            NodeId::new("node-terminal-test").unwrap(),
22545            vec![workspace],
22546            vec![agent("claude")],
22547        );
22548        shared.state_path = state_path;
22549        shared
22550    }
22551
22552    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22553        let mut spawn_spec = spawn_spec_fixture();
22554        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22555        ManagedWorktreeSpawnRequestV2 {
22556            spawn_spec,
22557            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22558            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22559        }
22560    }
22561
22562    #[test]
22563    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22564        let shared = terminal_test_shared();
22565        let mut lease = install_managed_lease(&shared);
22566        let session = SessionAddress {
22567            workspace_id: lease.workspace_id.clone(),
22568            session: SessionKey {
22569                instance_id: AgentInstanceId(73),
22570                generation: SessionGeneration(4),
22571            },
22572        };
22573        lease.state = ManagedWorktreeLeaseState::InUse;
22574        lease.session_holders.push(ManagedWorktreeSessionHolder {
22575            incarnation_id: shared.incarnation_id,
22576            instance_id: session.session.instance_id,
22577            generation: session.session.generation,
22578        });
22579        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22580        assert!(resolved.target.worktree_id.is_none());
22581
22582        let receipt = managed_spawn_receipt(
22583            &resolved,
22584            shared.incarnation_id,
22585            session.clone(),
22586            lease.snapshot(),
22587            None,
22588            None,
22589            None,
22590        );
22591        assert!(resolved.target.worktree_id.is_none());
22592        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22593        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22594        assert_eq!(receipt.spawn.session, session);
22595        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22596        let encoded = serde_json::to_vec(&receipt).unwrap();
22597        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22598        assert_eq!(decoded, receipt);
22599    }
22600
22601    #[tokio::test]
22602    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22603        let root = temporary_workspace_root("managed-v2-revision");
22604        let allocation = root.join("allocation");
22605        std::fs::create_dir_all(&allocation).unwrap();
22606        let shared = managed_v2_test_shared(
22607            &allocation,
22608            Some(root.join("node-state.json")),
22609            "v1",
22610        );
22611        let mut request = managed_v2_request("managed-v2-revision");
22612        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22613
22614        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22615
22616        assert_eq!(
22617            failure.code,
22618            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22619        );
22620        assert!(shared
22621            .managed_worktrees
22622            .lock()
22623            .unwrap_or_else(|poisoned| poisoned.into_inner())
22624            .records()
22625            .is_empty());
22626        assert!(shared
22627            .managed_spawn_replays
22628            .lock()
22629            .unwrap_or_else(|poisoned| poisoned.into_inner())
22630            .is_empty());
22631        std::fs::remove_dir_all(root).unwrap();
22632    }
22633
22634    #[test]
22635    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22636        let root = temporary_workspace_root("managed-v2-concurrent");
22637        let allocation = root.join("allocation");
22638        std::fs::create_dir_all(&allocation).unwrap();
22639        let shared = Arc::new(managed_v2_test_shared(
22640            &allocation,
22641            Some(root.join("node-state.json")),
22642            "v1",
22643        ));
22644        let request = managed_v2_request("managed-v2-concurrent");
22645        let barrier = Arc::new(std::sync::Barrier::new(3));
22646        let mut joins = Vec::new();
22647        for _ in 0..2 {
22648            let shared = Arc::clone(&shared);
22649            let request = request.clone();
22650            let barrier = Arc::clone(&barrier);
22651            joins.push(std::thread::spawn(move || {
22652                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22653                barrier.wait();
22654                shared.acquire_durable_managed_spawn_v2(&request, digest)
22655            }));
22656        }
22657        barrier.wait();
22658        let results = joins
22659            .into_iter()
22660            .map(|join| join.join().unwrap())
22661            .collect::<Vec<_>>();
22662        assert_eq!(
22663            results
22664                .iter()
22665                .filter(|result| {
22666                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22667                })
22668                .count(),
22669            1,
22670        );
22671        assert_eq!(
22672            results
22673                .iter()
22674                .filter(|result| {
22675                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22676                })
22677                .count(),
22678            1,
22679        );
22680        assert_eq!(
22681            shared
22682                .managed_spawn_replays
22683                .lock()
22684                .unwrap_or_else(|poisoned| poisoned.into_inner())
22685                .len(),
22686            1,
22687        );
22688        assert!(shared
22689            .managed_worktrees
22690            .lock()
22691            .unwrap_or_else(|poisoned| poisoned.into_inner())
22692            .records()
22693            .is_empty());
22694        drop(shared);
22695        std::fs::remove_dir_all(root).unwrap();
22696    }
22697
22698    #[tokio::test]
22699    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22700        let root = temporary_workspace_root("managed-v2-linked-pending");
22701        let allocation = root.join("allocation");
22702        std::fs::create_dir_all(&allocation).unwrap();
22703        let state_path = root.join("node-state.json");
22704        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22705        let lease = install_managed_lease(&shared);
22706        let request = managed_v2_request("managed-v2-linked-pending");
22707        assert!(matches!(
22708            shared
22709                .acquire_durable_managed_spawn_v2(
22710                    &request,
22711                    managed_spawn_request_digest_v2(&request).unwrap(),
22712                )
22713                .unwrap(),
22714            DurableManagedSpawnReplayDecision::Reserved,
22715        ));
22716        {
22717            let _transaction = shared
22718                .state_transaction
22719                .lock()
22720                .unwrap_or_else(|poisoned| poisoned.into_inner());
22721            shared
22722                .link_durable_managed_spawn_v2_lease_locked(
22723                    &request.spawn_spec.idempotency_key,
22724                    &lease.lease_id,
22725                )
22726                .unwrap();
22727            shared.persist_state_locked().unwrap();
22728        }
22729        drop(shared);
22730
22731        let mut loaded = session_registry::load(
22732            Some(&state_path),
22733            &NodeId::new("node-terminal-test").unwrap(),
22734        )
22735        .unwrap();
22736        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22737        *reopened
22738            .managed_worktrees
22739            .lock()
22740            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22741            ManagedWorktreeRegistry::from_records(
22742                std::mem::take(&mut loaded.managed_worktrees),
22743                std::mem::take(&mut loaded.managed_worktree_tombstones),
22744            )
22745            .unwrap();
22746        *reopened
22747            .managed_spawn_replays
22748            .lock()
22749            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22750            .managed_spawn_replays
22751            .into_iter()
22752            .map(|record| (record.idempotency_key.clone(), record))
22753            .collect();
22754        let lease_count = reopened
22755            .managed_worktrees
22756            .lock()
22757            .unwrap_or_else(|poisoned| poisoned.into_inner())
22758            .records()
22759            .len();
22760
22761        let failure = reopened
22762            .spawn_managed_worktree_v2(request)
22763            .await
22764            .unwrap_err();
22765
22766        assert_eq!(
22767            failure.code,
22768            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22769        );
22770        assert_eq!(
22771            reopened
22772                .managed_worktrees
22773                .lock()
22774                .unwrap_or_else(|poisoned| poisoned.into_inner())
22775                .records()
22776                .len(),
22777            lease_count,
22778        );
22779        std::fs::remove_dir_all(root).unwrap();
22780    }
22781
22782    #[tokio::test]
22783    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22784        let root = temporary_workspace_root("managed-v2-reopen");
22785        let allocation = root.join("allocation");
22786        std::fs::create_dir_all(&allocation).unwrap();
22787        let state_path = root.join("node-state.json");
22788        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22789        let lease = install_managed_lease(&shared);
22790        let request = managed_v2_request("managed-v2-reopen");
22791        let session = SessionAddress {
22792            workspace_id: lease.workspace_id.clone(),
22793            session: SessionKey {
22794                instance_id: AgentInstanceId(91),
22795                generation: SessionGeneration(1),
22796            },
22797        };
22798        let snapshot = {
22799            let mut registry = shared
22800                .managed_worktrees
22801                .lock()
22802                .unwrap_or_else(|poisoned| poisoned.into_inner());
22803            let current = registry.get_mut(&lease.lease_id).unwrap();
22804            current.state = ManagedWorktreeLeaseState::InUse;
22805            current.session_holders.push(ManagedWorktreeSessionHolder {
22806                incarnation_id: shared.incarnation_id,
22807                instance_id: session.session.instance_id,
22808                generation: session.session.generation,
22809            });
22810            current.snapshot()
22811        };
22812        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22813        let receipt = managed_spawn_receipt(
22814            &resolved,
22815            shared.incarnation_id,
22816            session,
22817            snapshot,
22818            None,
22819            None,
22820            None,
22821        );
22822        assert!(matches!(
22823            shared
22824                .acquire_durable_managed_spawn_v2(
22825                    &request,
22826                    managed_spawn_request_digest_v2(&request).unwrap(),
22827                )
22828                .unwrap(),
22829            DurableManagedSpawnReplayDecision::Reserved,
22830        ));
22831        {
22832            let _transaction = shared
22833                .state_transaction
22834                .lock()
22835                .unwrap_or_else(|poisoned| poisoned.into_inner());
22836            shared
22837                .link_durable_managed_spawn_v2_lease_locked(
22838                    &request.spawn_spec.idempotency_key,
22839                    &lease.lease_id,
22840                )
22841                .unwrap();
22842            shared.persist_state_locked().unwrap();
22843        }
22844        shared
22845            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22846            .unwrap();
22847        let legacy_same_key = ManagedWorktreeSpawnRequest {
22848            spawn_spec: request.spawn_spec.clone(),
22849            worktree_profile_id: request.worktree_profile_id.clone(),
22850        };
22851        shared.remember_managed_spawn_attempt(
22852            Some(&request.spawn_spec.idempotency_key),
22853            legacy_same_key.clone(),
22854            Ok(receipt.clone()),
22855            Instant::now(),
22856        );
22857        assert!(shared
22858            .replay_managed_spawn(&legacy_same_key, Instant::now())
22859            .unwrap()
22860            .is_none());
22861        drop(shared);
22862
22863        let mut loaded = session_registry::load(
22864            Some(&state_path),
22865            &NodeId::new("node-terminal-test").unwrap(),
22866        )
22867        .unwrap();
22868        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22869        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22870        *reopened
22871            .managed_worktrees
22872            .lock()
22873            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22874            ManagedWorktreeRegistry::from_records(
22875                std::mem::take(&mut loaded.managed_worktrees),
22876                std::mem::take(&mut loaded.managed_worktree_tombstones),
22877            )
22878            .unwrap();
22879        *reopened
22880            .managed_spawn_replays
22881            .lock()
22882            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22883            .managed_spawn_replays
22884            .into_iter()
22885            .map(|record| (record.idempotency_key.clone(), record))
22886            .collect();
22887        let lease_count_before = reopened
22888            .managed_worktrees
22889            .lock()
22890            .unwrap_or_else(|poisoned| poisoned.into_inner())
22891            .records()
22892            .len();
22893
22894        assert_eq!(
22895            reopened
22896                .spawn_managed_worktree_v2(request.clone())
22897                .await
22898                .unwrap(),
22899            receipt,
22900        );
22901        assert_eq!(
22902            reopened
22903                .managed_worktrees
22904                .lock()
22905                .unwrap_or_else(|poisoned| poisoned.into_inner())
22906                .records()
22907                .len(),
22908            lease_count_before,
22909        );
22910        let mut changed = request;
22911        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22912        let failure = reopened
22913            .spawn_managed_worktree_v2(changed)
22914            .await
22915            .unwrap_err();
22916        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22917        assert_eq!(
22918            reopened
22919                .managed_worktrees
22920                .lock()
22921                .unwrap_or_else(|poisoned| poisoned.into_inner())
22922                .records()
22923                .len(),
22924            lease_count_before,
22925        );
22926        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22927        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22928        let failure = reopened
22929            .spawn_managed_worktree_v2(new_key)
22930            .await
22931            .unwrap_err();
22932        assert_eq!(
22933            failure.code,
22934            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22935        );
22936        assert_eq!(
22937            reopened
22938                .managed_worktrees
22939                .lock()
22940                .unwrap_or_else(|poisoned| poisoned.into_inner())
22941                .records()
22942                .len(),
22943            lease_count_before,
22944        );
22945        assert_eq!(
22946            reopened
22947                .managed_spawn_replays
22948                .lock()
22949                .unwrap_or_else(|poisoned| poisoned.into_inner())
22950                .len(),
22951            1,
22952        );
22953        std::fs::remove_dir_all(root).unwrap();
22954    }
22955
22956    #[test]
22957    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22958        let shared = terminal_test_shared();
22959        let request = ManagedWorktreeSpawnRequest {
22960            spawn_spec: spawn_spec_fixture(),
22961            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22962        };
22963        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22964        shared.remember_managed_spawn(
22965            request.clone(),
22966            Err(expected.clone()),
22967            Instant::now(),
22968        );
22969
22970        assert_eq!(
22971            shared
22972                .replay_managed_spawn(&request, Instant::now())
22973                .unwrap()
22974                .unwrap(),
22975            Err(expected),
22976        );
22977        assert!(shared
22978            .managed_spawn_replays
22979            .lock()
22980            .unwrap_or_else(|poisoned| poisoned.into_inner())
22981            .is_empty());
22982    }
22983
22984    #[tokio::test]
22985    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22986        let shared = terminal_test_shared();
22987        let lease = install_managed_lease(&shared);
22988        let raw = shared.spawn_session(
22989            lease.workspace_id.clone(),
22990            agent("claude"),
22991            SessionMode::Pty,
22992            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22993            None,
22994        ).await.unwrap_err();
22995        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22996        let mut spec = spawn_spec_fixture();
22997        spec.target.workspace_id = lease.workspace_id.clone();
22998        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22999        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23000        let mut selected = spawn_spec_fixture();
23001        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23002        selected.target.worktree_id = Some(lease.workspace_id);
23003        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23004        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23005        assert!(shared.handle.snapshot().sessions.is_empty());
23006    }
23007
23008    #[test]
23009    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23010        let shared = terminal_test_shared();
23011        let lease = install_managed_lease(&shared);
23012        let snapshot = snapshot_for_wire(
23013            &shared, true, true, true, false, true, true, false, false, true,
23014        );
23015        assert!(snapshot.managed_worktrees.is_empty());
23016        assert!(snapshot.workspaces.iter().all(|workspace| {
23017            workspace.managed_worktree_profiles.is_none()
23018        }));
23019        let mut reply = ResponseEnvelope {
23020            request_id: 1,
23021            result: Ok(NodeResponse::Resync {
23022                event_sequence: 1,
23023                oldest_available_sequence: 1,
23024                snapshot: shared.snapshot(),
23025                events: vec![NodeEventEnvelope {
23026                    sequence: 1,
23027                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23028                }],
23029            }),
23030        };
23031        project_response_without_managed_worktrees(&mut reply);
23032        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23033            panic!("projection changed the response kind");
23034        };
23035        assert!(snapshot.managed_worktrees.is_empty());
23036        assert!(events.is_empty());
23037        let mut git = git_snapshot_for_parser();
23038        git.branch = Some(lease.branch.clone());
23039        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23040            lease_id: lease.lease_id.clone(),
23041            source_workspace_id: lease.source_workspace_id.clone(),
23042            branch: lease.branch.clone(),
23043            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23044            active_session_count: 1,
23045            managed_record_count: 0,
23046        });
23047        let mut inspection_reply = ResponseEnvelope {
23048            request_id: 2,
23049            result: Ok(NodeResponse::WorkspaceInspected {
23050                inspection: WorkspaceInspection {
23051                    workspace_id: lease.workspace_id.clone(),
23052                    entries: Vec::new(),
23053                    tree_truncated: false,
23054                    git,
23055                    truncation: None,
23056                },
23057            }),
23058        };
23059        project_response_without_managed_worktrees(&mut inspection_reply);
23060        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23061            panic!("projection changed the inspection response kind");
23062        };
23063        assert!(inspection.git.managed_worktree.is_none());
23064        let failure = managed_git_worktree_failure(GitWorktreeError {
23065            kind: GitWorktreeErrorKind::Failed,
23066            message: r"git failed at C:\private\managed-target".to_owned(),
23067        });
23068        assert!(!failure.message.contains("private"));
23069        assert!(!failure.message.contains("managed-target"));
23070    }
23071
23072    #[tokio::test]
23073    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23074        let root = temporary_workspace_root("managed-restart");
23075        let source = root.join("source");
23076        let allocation = root.join("allocation");
23077        std::fs::create_dir_all(&source).unwrap();
23078        std::fs::create_dir_all(&allocation).unwrap();
23079        run_test_git(&source, &["init"]);
23080        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23081        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23082        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23083        run_test_git(&source, &["add", "seed.txt"]);
23084        run_test_git(&source, &["commit", "-m", "seed"]);
23085        let profile = ManagedWorktreeProfile::new(
23086            WorktreeProfileId::new("default").unwrap(),
23087            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23088            &allocation,
23089            "gate4agent",
23090            "HEAD",
23091            ManagedWorktreeRetention::Retain,
23092        ).unwrap();
23093        let workspace = WorkspaceConfig::new(
23094            WorkspaceId::new("primary").unwrap(),
23095            &source,
23096        ).unwrap()
23097            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23098            .with_managed_worktree_profile(profile.clone()).unwrap();
23099        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23100        let source_text = workspace.canonical_root().to_owned();
23101        let target_text = target.to_string_lossy().into_owned();
23102        let created = create_git_worktree(
23103            &source_text,
23104            &target_text,
23105            "gate4agent/mw-restart",
23106            Some("HEAD"),
23107        ).await.unwrap();
23108        let lease = ManagedWorktreeLeaseRecord {
23109            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23110            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23111            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23112            profile_id: profile.profile_id().clone(),
23113            profile_revision: profile.revision().clone(),
23114            target_root: created.path.clone(),
23115            branch: "gate4agent/mw-restart".to_owned(),
23116            base_commit: created.head.clone(),
23117            expected_head: None,
23118            retention: ManagedWorktreeRetention::Retain,
23119            state: ManagedWorktreeLeaseState::Allocating,
23120            session_holders: Vec::new(),
23121            record_holders: Vec::new(),
23122            cleanup_failure: None,
23123            created_at_unix_ms: 1,
23124            updated_at_unix_ms: 1,
23125        };
23126        let catalog = active_registry().unwrap();
23127        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23128        let shared = NodeShared::new_with_incarnation(
23129            handle,
23130            "fixture-token".to_owned(),
23131            NodeId::new("node-managed-restart").unwrap(),
23132            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23133            vec![workspace],
23134            vec![agent("claude")],
23135            ProviderRuntimeStatuses::default(),
23136            None,
23137            Vec::new(),
23138            Vec::new(),
23139            SpawnProfileRegistry::default(),
23140            None,
23141            None,
23142            None,
23143            None,
23144            Vec::new(),
23145            vec![lease.clone()],
23146            Vec::new(),
23147            Vec::new(),
23148            None,
23149            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23150        );
23151        shared.reconcile_managed_worktrees().await;
23152        let retained = shared.managed_worktrees.lock()
23153            .unwrap_or_else(|poisoned| poisoned.into_inner())
23154            .get(&lease.lease_id).unwrap().clone();
23155        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23156        assert!(shared.workspaces.read()
23157            .unwrap_or_else(|poisoned| poisoned.into_inner())
23158            .contains_key(&lease.workspace_id));
23159        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23160        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23161        assert!(!Path::new(&created.path).exists());
23162        std::fs::remove_dir_all(root).unwrap();
23163    }
23164
23165    fn run_test_git(root: &Path, arguments: &[&str]) {
23166        let output = std::process::Command::new("git")
23167            .arg("-C").arg(root).args(arguments).output().unwrap();
23168        assert!(
23169            output.status.success(),
23170            "git {:?} failed: {}",
23171            arguments,
23172            String::from_utf8_lossy(&output.stderr),
23173        );
23174    }
23175
23176    #[tokio::test]
23177    async fn spawn_spec_validation_fails_before_session_mutation() {
23178        let shared = terminal_test_shared();
23179        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23180            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23181        }));
23182        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23183            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23184        }));
23185        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23186            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23187        }));
23188
23189        let mut target_mismatch = spawn_spec_fixture();
23190        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23191        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23192        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23193
23194        let mut unknown_profile = spawn_spec_fixture();
23195        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23196        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23197        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23198        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23199
23200        let mut revision_mismatch = spawn_spec_fixture();
23201        revision_mismatch.idempotency_key =
23202            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23203        revision_mismatch.expected_profile_revision =
23204            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23205        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23206        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23207
23208        let mut unsupported_materializer = spawn_spec_fixture();
23209        unsupported_materializer.idempotency_key =
23210            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23211        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23212            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23213        };
23214        let failure = shared
23215            .spawn_from_spec(unsupported_materializer)
23216            .await
23217            .unwrap_err();
23218        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23219
23220        let failure = shared
23221            .spawn_session_with_deadline(
23222                WorkspaceId::new("primary").unwrap(),
23223                agent("claude"),
23224                SessionMode::Pty,
23225                gate4agent_types::TerminalSize {
23226                    rows: 24,
23227                    columns: 80,
23228                },
23229                None,
23230                None,
23231                None,
23232                None,
23233                None,
23234                None,
23235                SpawnRecordPolicy::ProviderIdentityOnly,
23236                Some(Instant::now()),
23237                &[],
23238                None,
23239                ApprovalLevel::default(),
23240                None,
23241                None,
23242            )
23243            .await
23244            .unwrap_err();
23245        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23246        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23247        assert!(shared.handle.snapshot().sessions.is_empty());
23248    }
23249
23250    #[test]
23251    fn worktree_selection_requires_its_separate_negotiated_capability() {
23252        let mut spec = spawn_spec_fixture();
23253        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23254        let request = NodeRequest::SpawnSpec { spec };
23255        let spawn_spec_capability =
23256            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23257        assert!(request_uses_unnegotiated_capability(
23258            &request,
23259            &[spawn_spec_capability.clone()],
23260        ));
23261        let worktree_capability =
23262            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23263        assert!(request_uses_unnegotiated_capability(
23264            &request,
23265            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23266        ));
23267        let profile_revision_capability =
23268            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23269        assert!(request_uses_unnegotiated_capability(
23270            &request,
23271            &[
23272                spawn_spec_capability.clone(),
23273                worktree_capability.clone(),
23274                profile_revision_capability.clone(),
23275            ],
23276        ));
23277        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23278        // `Inherit`, not `Clear` — the profile's own defaults might still
23279        // materialize a bundle or a context pack, so both capabilities stay
23280        // conservatively required until negotiated (see
23281        // `requires_session_bundle_materialization_capability` /
23282        // `requires_history_context_pack_capability`).
23283        let bundle_materialization_capability =
23284            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23285        assert!(request_uses_unnegotiated_capability(
23286            &request,
23287            &[
23288                spawn_spec_capability.clone(),
23289                worktree_capability.clone(),
23290                profile_revision_capability.clone(),
23291                bundle_materialization_capability.clone(),
23292            ],
23293        ));
23294        let history_context_pack_capability =
23295            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23296        assert!(!request_uses_unnegotiated_capability(
23297            &request,
23298            &[
23299                spawn_spec_capability,
23300                worktree_capability,
23301                profile_revision_capability,
23302                bundle_materialization_capability,
23303                history_context_pack_capability,
23304            ],
23305        ));
23306    }
23307
23308    #[tokio::test]
23309    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23310        let mut shared = terminal_test_shared();
23311        let spec = spawn_spec_fixture();
23312        let receipt = shared
23313            .resolve_spawn_spec(&spec)
23314            .unwrap()
23315            .receipt(shared.incarnation_id, terminal_address(1));
23316        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23317
23318        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23319        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23320        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23321
23322        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23323
23324        let mut revision_conflicting = spec.clone();
23325        revision_conflicting.expected_profile_revision =
23326            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23327        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23328        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23329
23330        let mut stale_cache_miss = spec.clone();
23331        stale_cache_miss.idempotency_key =
23332            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23333        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23334        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23335
23336        let mut conflicting = spec;
23337        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23338        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23339        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23340        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23341        assert!(shared.handle.snapshot().sessions.is_empty());
23342    }
23343
23344    #[tokio::test]
23345    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23346        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23347        let mut spec = spawn_spec_fixture();
23348        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23349        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23350        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23351
23352        let failure = shared
23353            .create_worktree(
23354                WorkspaceId::new("primary").unwrap(),
23355                WorkspaceId::new("new-worktree").unwrap(),
23356                "not-an-absolute-path".to_owned(),
23357                "invalid branch".to_owned(),
23358                None,
23359            )
23360            .await
23361            .unwrap_err();
23362        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23363
23364        let failure = shared
23365            .remove_worktree(
23366                WorkspaceId::new("primary").unwrap(),
23367                "not-an-absolute-path".to_owned(),
23368            )
23369            .await
23370            .unwrap_err();
23371        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23372        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23373        assert!(shared.handle.snapshot().sessions.is_empty());
23374    }
23375
23376    #[tokio::test]
23377    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23378        let root = temporary_workspace_root("manual-worktree-compensation");
23379        let source = root.join("source");
23380        let target = root.join("target");
23381        let invalid_state_parent = root.join("state-parent-is-a-file");
23382        let invalid_state_path = invalid_state_parent.join("state.json");
23383        std::fs::create_dir_all(&source).unwrap();
23384        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23385        run_test_git(&source, &["init"]);
23386        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23387        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23388        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23389        run_test_git(&source, &["add", "seed.txt"]);
23390        run_test_git(&source, &["commit", "-m", "seed"]);
23391
23392        let catalog = active_registry().unwrap();
23393        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23394        let workspace = WorkspaceConfig::new(
23395            WorkspaceId::new("primary").unwrap(),
23396            &source,
23397        ).unwrap();
23398        let mut shared = NodeShared::new(
23399            handle,
23400            "fixture-token".to_owned(),
23401            NodeId::new("node-worktree-compensation").unwrap(),
23402            vec![workspace],
23403            vec![agent("claude")],
23404        );
23405        shared.state_path = Some(invalid_state_path);
23406
23407        let error = shared.create_worktree(
23408            WorkspaceId::new("primary").unwrap(),
23409            WorkspaceId::new("compensated").unwrap(),
23410            target.to_string_lossy().into_owned(),
23411            "gate4agent/compensated".to_owned(),
23412            Some("HEAD".to_owned()),
23413        ).await.unwrap_err();
23414
23415        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23416        assert!(!target.exists());
23417        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23418            .iter()
23419            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23420        std::fs::remove_dir_all(root).unwrap();
23421    }
23422
23423    #[test]
23424    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23425        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23426            NativeGitWorktreeSnapshot {
23427                path: path.to_owned(),
23428                head: "0123456789abcdef".to_owned(),
23429                branch: Some("refs/heads/test".to_owned()),
23430                is_bare,
23431                is_main,
23432                locked: false,
23433                lock_reason: None,
23434                prunable: false,
23435                prunable_reason: None,
23436                workspace_id: None,
23437            }
23438        }
23439
23440        let source = r"C:\repo";
23441        let selected = r"C:\repo-worktree";
23442        let listed = vec![
23443            worktree(source, true, false),
23444            worktree(selected, false, false),
23445        ];
23446        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23447
23448        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23449        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23450
23451        let bare = vec![
23452            worktree(source, true, false),
23453            worktree(selected, false, true),
23454        ];
23455        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23456        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23457
23458        let foreign_failure =
23459            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23460        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23461    }
23462
23463    fn terminal_frame(sequence: u64) -> TerminalFrame {
23464        TerminalFrame {
23465            sequence,
23466            size: gate4agent_types::TerminalSize {
23467                rows: 24,
23468                columns: 80,
23469            },
23470            cursor_row: 2,
23471            cursor_column: 3,
23472            contents: format!("frame-{sequence}"),
23473            formatted: vec![1, 2, 3],
23474            scrollback_formatted: Vec::new(),
23475            alternate_screen: false,
23476            mouse_protocol_enabled: false,
23477            mouse_protocol_encoding: Default::default(),
23478            produced_at_unix_ms: 0,
23479            screen_state: gate4agent_types::PtyScreenState::default(),
23480            bracketed_paste: None,
23481        }
23482    }
23483
23484    #[test]
23485    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23486        let shared = terminal_test_shared();
23487        let mut receiver = shared.terminal_event_tx.subscribe();
23488        let original = terminal_address(1);
23489
23490        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23491        let first = receiver.try_recv().unwrap();
23492        assert_eq!(first.len(), 1);
23493        assert!(matches!(
23494            &first[0].event,
23495            NodeEvent::TerminalFrame { address, frame }
23496                if address == &original && frame.sequence == 7
23497        ));
23498
23499        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23500        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23501        assert!(matches!(
23502            receiver.try_recv(),
23503            Err(broadcast::error::TryRecvError::Empty)
23504        ));
23505
23506        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23507        let advanced = receiver.try_recv().unwrap();
23508        assert_eq!(advanced[0].sequence, 2);
23509
23510        let resumed = terminal_address(2);
23511        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23512        let reset = receiver.try_recv().unwrap();
23513        assert_eq!(reset[0].sequence, 3);
23514        assert!(matches!(
23515            &reset[0].event,
23516            NodeEvent::TerminalFrame { address, frame }
23517                if address == &resumed && frame.sequence == 1
23518        ));
23519        shared.clear_terminal_frame_watermark(&resumed);
23520        assert!(!shared
23521            .terminal_frame_watermarks
23522            .lock()
23523            .unwrap_or_else(|poisoned| poisoned.into_inner())
23524            .contains_key(&resumed.session.instance_id));
23525    }
23526
23527    #[test]
23528    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23529        let shared = terminal_test_shared();
23530        let mut receiver = shared.terminal_event_tx.subscribe();
23531        let address = terminal_address(1);
23532        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23533
23534        for sequence in 1..=count {
23535            shared.publish_terminal_frame_candidates(vec![(
23536                address.clone(),
23537                terminal_frame(sequence),
23538            )]);
23539        }
23540
23541        let history = shared
23542            .history
23543            .lock()
23544            .unwrap_or_else(|poisoned| poisoned.into_inner());
23545        assert!(history.events.is_empty());
23546        assert_eq!(history.last_sequence, count);
23547        assert_eq!(history.replay_floor_sequence, 1);
23548        drop(history);
23549        let NodeResponse::Resync {
23550            event_sequence,
23551            oldest_available_sequence,
23552            events,
23553            ..
23554        } = shared.resync(0) else {
23555            unreachable!("resync helper returned another response")
23556        };
23557        assert_eq!(event_sequence, count);
23558        assert_eq!(oldest_available_sequence, 1);
23559        assert!(events.is_empty());
23560        assert!(matches!(
23561            resync_required_event(&shared).event,
23562            NodeEvent::ResyncRequired {
23563                oldest_available_sequence: 1,
23564            }
23565        ));
23566        assert!(matches!(
23567            receiver.try_recv(),
23568            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23569        ));
23570        let latest = receiver.try_recv().unwrap();
23571        assert_eq!(latest.len(), 1);
23572        assert_eq!(latest[0].sequence, count);
23573    }
23574
23575    #[test]
23576    fn real_durable_eviction_advances_authoritative_replay_floor() {
23577        let shared = terminal_test_shared();
23578        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23579            shared.publish(NodeEvent::ControllerChanged { controller: None });
23580        }
23581
23582        let NodeResponse::Resync {
23583            event_sequence,
23584            oldest_available_sequence,
23585            events,
23586            ..
23587        } = shared.resync(0) else {
23588            unreachable!("resync helper returned another response")
23589        };
23590        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23591        assert_eq!(oldest_available_sequence, 2);
23592        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23593        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23594        assert!(matches!(
23595            resync_required_event(&shared).event,
23596            NodeEvent::ResyncRequired {
23597                oldest_available_sequence: 2,
23598            }
23599        ));
23600    }
23601
23602    #[test]
23603    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23604        let shared = terminal_test_shared();
23605        let without_capability = terminal_event_subscription(&shared, false);
23606        let with_capability = terminal_event_subscription(&shared, true);
23607        assert!(without_capability.is_none());
23608        assert!(with_capability.is_some());
23609        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23610            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23611        }));
23612    }
23613
23614    #[test]
23615    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23616        let mut pending = Vec::new();
23617        let mut event_sequence = 0_u64;
23618        for frame_sequence in 1..=4 {
23619            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23620                event_sequence += 1;
23621                let mut address = terminal_address(1);
23622                address.session.instance_id = AgentInstanceId(instance);
23623                queue_connection_event(
23624                    &mut pending,
23625                    NodeEventEnvelope {
23626                        sequence: event_sequence,
23627                        event: NodeEvent::TerminalFrame {
23628                            address,
23629                            frame: terminal_frame(frame_sequence),
23630                        },
23631                    },
23632                    0,
23633                );
23634            }
23635        }
23636        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23637        pending.sort_unstable_by_key(|event| event.sequence);
23638
23639        let mut uninterrupted = pending.clone();
23640        let first_burst = connection_event_burst_len(uninterrupted.len());
23641        uninterrupted.drain(..first_burst);
23642        let second_burst = connection_event_burst_len(uninterrupted.len());
23643        uninterrupted.drain(..second_burst);
23644        assert!(uninterrupted.is_empty());
23645
23646        let (frame_tx, mut frame_rx) =
23647            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23648        frame_tx
23649            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23650                request_id: 77,
23651                request: NodeRequest::Snapshot,
23652            })))
23653            .unwrap();
23654        let burst_len = connection_event_burst_len(pending.len());
23655        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23656        pending.drain(..burst_len);
23657        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23658        assert!(matches!(
23659            frame_rx.try_recv(),
23660            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23661                request_id: 77,
23662                request: NodeRequest::Snapshot,
23663            })))
23664        ));
23665    }
23666
23667    #[test]
23668    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23669        let mut pending = Vec::new();
23670        for sequence in [7_u64, 8] {
23671            let mut address = terminal_address(1);
23672            address.session.instance_id = AgentInstanceId(sequence);
23673            queue_connection_event(
23674                &mut pending,
23675                NodeEventEnvelope {
23676                    sequence,
23677                    event: NodeEvent::TerminalFrame {
23678                        address,
23679                        frame: terminal_frame(sequence),
23680                    },
23681                },
23682                0,
23683            );
23684        }
23685        let mut discard_through = 0;
23686        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23687        assert!(pending.is_empty());
23688
23689        let replayed = NodeEventEnvelope {
23690            sequence: 8,
23691            event: NodeEvent::TerminalFrame {
23692                address: terminal_address(1),
23693                frame: terminal_frame(8),
23694            },
23695        };
23696        let advanced = NodeEventEnvelope {
23697            sequence: 9,
23698            event: NodeEvent::TerminalFrame {
23699                address: terminal_address(2),
23700                frame: terminal_frame(1),
23701            },
23702        };
23703        queue_connection_event_batch(
23704            &mut pending,
23705            &[replayed, advanced.clone()],
23706            discard_through,
23707        );
23708        assert_eq!(pending, vec![advanced]);
23709    }
23710
23711    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23712        ManagedSessionRecord {
23713            record_id: SessionRecordId::new(record_id).unwrap(),
23714            display_name: format!("{provider} record"),
23715            provider: agent(provider),
23716            mode: SessionMode::Pty,
23717            state: ManagedSessionState::Unavailable,
23718            workspace_id: WorkspaceId::new("primary").unwrap(),
23719            canonical_root: opaque_windows_path(
23720                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23721            ),
23722            provider_session: None,
23723            active_session: None,
23724            environment_profile: None,
23725            bundle: None,
23726            context_id: None,
23727            context: None,
23728            exported_context: None,
23729            task_binding: None,
23730            created_at_unix_ms: 1,
23731            updated_at_unix_ms: 1,
23732            last_error: None,
23733        }
23734    }
23735
23736    fn retention_test_record(
23737        workspace_id: &str,
23738        record_id: &str,
23739        state: ManagedSessionState,
23740        updated_at_unix_ms: u64,
23741    ) -> ManagedSessionRecord {
23742        ManagedSessionRecord {
23743            record_id: SessionRecordId::new(record_id).unwrap(),
23744            display_name: format!("{record_id} display"),
23745            provider: agent("claude"),
23746            mode: SessionMode::Pty,
23747            state,
23748            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23749            canonical_root: opaque_windows_path(
23750                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23751            ),
23752            provider_session: None,
23753            active_session: None,
23754            environment_profile: None,
23755            bundle: None,
23756            context_id: None,
23757            context: None,
23758            exported_context: None,
23759            task_binding: None,
23760            created_at_unix_ms: updated_at_unix_ms,
23761            updated_at_unix_ms,
23762            last_error: None,
23763        }
23764    }
23765
23766    /// Two workspaces of three `Unavailable` records each, staggered by
23767    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23768    /// `workspace-a` (all as old as that workspace's oldest real record --
23769    /// if the sweep's state gate ever leaked, these would be the first
23770    /// thing it wrongly retired). Ages, oldest to newest:
23771    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23772    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23773    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23774        vec![
23775            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23776            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23777            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23778            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23779            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23780            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23781            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23782            retention_test_record(
23783                "workspace-a",
23784                "decoy-pending",
23785                ManagedSessionState::IdentityPending,
23786                now - 500_000,
23787            ),
23788            retention_test_record(
23789                "workspace-a",
23790                "decoy-dormant",
23791                ManagedSessionState::Dormant,
23792                now - 500_000,
23793            ),
23794        ]
23795    }
23796
23797    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23798        let root = temporary_workspace_root("session-record-retention");
23799        std::fs::create_dir_all(&root).unwrap();
23800        let catalog = active_registry().unwrap();
23801        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23802        let workspace_a =
23803            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23804        let workspace_b =
23805            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23806        let mut shared = NodeShared::new(
23807            handle,
23808            "fixture-token".to_owned(),
23809            NodeId::new("node-retention-test").unwrap(),
23810            vec![workspace_a, workspace_b],
23811            vec![agent("claude")],
23812        );
23813        shared.state_path = Some(root.join("node-state-v10.json"));
23814        let now = unix_time_ms();
23815        for record in retention_fixture_records(now) {
23816            shared.insert_record(record).unwrap();
23817        }
23818        shared
23819            .persist_state_locked()
23820            .expect("fixture-only, empty-history persist against a fresh temp state path");
23821        (shared, runtime, root, now)
23822    }
23823
23824    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23825        shared
23826            .history
23827            .lock()
23828            .unwrap_or_else(|poisoned| poisoned.into_inner())
23829            .events
23830            .iter()
23831            .filter_map(|envelope| match &envelope.event {
23832                NodeEvent::SessionRecordRemoved { record_id } => {
23833                    Some(record_id.as_str().to_owned())
23834                }
23835                _ => None,
23836            })
23837            .collect()
23838    }
23839
23840    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23841        shared
23842            .session_records
23843            .lock()
23844            .unwrap_or_else(|poisoned| poisoned.into_inner())
23845            .records
23846            .keys()
23847            .map(|record_id| record_id.as_str().to_owned())
23848            .collect()
23849    }
23850
23851    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23852        values.iter().map(|value| (*value).to_owned()).collect()
23853    }
23854
23855    #[tokio::test]
23856    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23857        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23858        shared.session_record_retention = SessionRecordRetentionConfig {
23859            age_ms: 0,
23860            keep_per_workspace: 1,
23861        };
23862        shared.retire_unavailable_session_records().await;
23863
23864        assert_eq!(
23865            retention_remaining_record_ids(&shared),
23866            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23867        );
23868        assert_eq!(
23869            retention_removed_record_ids(&shared),
23870            owned(&["a1", "a2", "b1", "b2"]),
23871        );
23872        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23873
23874        drop(shared);
23875        drop(runtime);
23876        std::fs::remove_dir_all(root).unwrap();
23877    }
23878
23879    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23880    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23881    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23882    /// 200s old against a 250s threshold) even though it fails the keep-N
23883    /// test too, proving retirement needs BOTH, not either.
23884    #[tokio::test]
23885    async fn session_record_retention_both_set_retires_only_the_intersection() {
23886        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23887        shared.session_record_retention = SessionRecordRetentionConfig {
23888            age_ms: 250_000,
23889            keep_per_workspace: 1,
23890        };
23891        shared.retire_unavailable_session_records().await;
23892
23893        assert_eq!(
23894            retention_remaining_record_ids(&shared),
23895            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23896        );
23897        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23898        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23899
23900        drop(shared);
23901        drop(runtime);
23902        std::fs::remove_dir_all(root).unwrap();
23903    }
23904
23905    #[tokio::test]
23906    async fn session_record_retention_both_disabled_is_a_no_op() {
23907        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23908        shared.session_record_retention = SessionRecordRetentionConfig::default();
23909        shared.retire_unavailable_session_records().await;
23910
23911        assert_eq!(
23912            retention_remaining_record_ids(&shared),
23913            owned(&[
23914                "a1", "a2", "a3", "b1", "b2", "b3",
23915                "decoy-live", "decoy-pending", "decoy-dormant",
23916            ]),
23917        );
23918        assert!(retention_removed_record_ids(&shared).is_empty());
23919        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23920
23921        drop(shared);
23922        drop(runtime);
23923        std::fs::remove_dir_all(root).unwrap();
23924    }
23925
23926    #[test]
23927    fn session_record_context_export_selects_one_exact_history_identity() {
23928        let candidates = vec![
23929            HistoryCandidateSummary {
23930                id: "candidate-other".to_owned(),
23931                session_id_hint: "provider-other".to_owned(),
23932                modified_at_unix_ms: Some(1),
23933            },
23934            HistoryCandidateSummary {
23935                id: "candidate-exact".to_owned(),
23936                session_id_hint: "provider-exact".to_owned(),
23937                modified_at_unix_ms: Some(2),
23938            },
23939        ];
23940        assert_eq!(
23941            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23942            "candidate-exact",
23943        );
23944    }
23945
23946    #[test]
23947    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23948        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23949            id: id.to_owned(),
23950            session_id_hint: hint.to_owned(),
23951            modified_at_unix_ms: None,
23952        };
23953        let missing = select_history_candidate(
23954            &[candidate("candidate-other", "provider-other")],
23955            Some("provider-exact"),
23956        )
23957        .unwrap_err();
23958        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23959        let ambiguous = select_history_candidate(
23960            &[
23961                candidate("candidate-a", "provider-exact"),
23962                candidate("candidate-b", "provider-exact"),
23963            ],
23964            Some("provider-exact"),
23965        )
23966        .unwrap_err();
23967        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23968    }
23969
23970    /// The PTY-sidecar-observation shape (no fleet provider today, but
23971    /// capability-keyed rather than vendor-locked) never carries a verified
23972    /// `provider_session`; its record-bound export instead falls back to
23973    /// "exactly one discovered candidate" (see
23974    /// `export_context_pack_for_session_record_inner`). This is the same
23975    /// ambiguity refusal as the identity-filtered path, just without an
23976    /// identity to filter by.
23977    #[test]
23978    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23979        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23980            id: id.to_owned(),
23981            session_id_hint: hint.to_owned(),
23982            modified_at_unix_ms: None,
23983        };
23984        assert_eq!(
23985            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23986                .unwrap(),
23987            "candidate-sole",
23988        );
23989        let missing = select_history_candidate(&[], None).unwrap_err();
23990        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23991        let ambiguous = select_history_candidate(
23992            &[
23993                candidate("candidate-a", "sidecar-session-a"),
23994                candidate("candidate-b", "sidecar-session-b"),
23995            ],
23996            None,
23997        )
23998        .unwrap_err();
23999        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24000    }
24001
24002    #[test]
24003    fn session_record_context_export_revalidation_rejects_record_change() {
24004        let session = terminal_address(1);
24005        let identity = ProviderSessionIdentity {
24006            key: ProviderSessionKey::SessionId,
24007            id: "provider-exact".to_owned(),
24008            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24009        };
24010        let mut expected = record("claude", "record-context-export");
24011        expected.state = ManagedSessionState::Live;
24012        expected.provider_session = Some(identity.clone());
24013        expected.active_session = Some(session.clone());
24014        let binding = SessionBinding {
24015            workspace_id: session.workspace_id.clone(),
24016            generation: session.session.generation,
24017            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24018            pending_resume: None,
24019            record_id: Some(expected.record_id.clone()),
24020            managed_worktree_lease_id: None,
24021            environment_profile: None,
24022            bundle: None,
24023            context: None,
24024            materialization_id: None,
24025        };
24026        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24027        assert!(session_record_context_export_binding_is_exact(
24028            &expected,
24029            &expected,
24030            Some(&identity),
24031            &session,
24032            &binding,
24033            &expected.provider,
24034            &current_root,
24035            false,
24036        ));
24037        let mut changed = expected.clone();
24038        changed.updated_at_unix_ms += 1;
24039        assert!(!session_record_context_export_binding_is_exact(
24040            &expected,
24041            &changed,
24042            Some(&identity),
24043            &session,
24044            &binding,
24045            &expected.provider,
24046            &current_root,
24047            false,
24048        ));
24049        // A same-instant timestamp bump alone (state and active_session
24050        // otherwise unchanged) is not the clean-detachment transition
24051        // either, even with the reactive flag on: only the exact Live ->
24052        // Dormant + active_session cleared shape qualifies.
24053        assert!(!session_record_context_export_binding_is_exact(
24054            &expected,
24055            &changed,
24056            Some(&identity),
24057            &session,
24058            &binding,
24059            &expected.provider,
24060            &current_root,
24061            true,
24062        ));
24063        let mut detached = expected.clone();
24064        detached.state = ManagedSessionState::Dormant;
24065        detached.active_session = None;
24066        detached.updated_at_unix_ms += 1;
24067        assert!(!session_record_context_export_binding_is_exact(
24068            &expected,
24069            &detached,
24070            Some(&identity),
24071            &session,
24072            &binding,
24073            &expected.provider,
24074            &current_root,
24075            false,
24076        ));
24077        assert!(session_record_context_export_binding_is_exact(
24078            &expected,
24079            &detached,
24080            Some(&identity),
24081            &session,
24082            &binding,
24083            &expected.provider,
24084            &current_root,
24085            true,
24086        ));
24087    }
24088
24089    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24090    /// for a record whose provider never carries a verified identity (the
24091    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24092    /// declares one today): `identity` is `None` throughout, and the
24093    /// exactness check falls back to comparing `provider_session` itself,
24094    /// which must still be `None` on both sides.
24095    #[test]
24096    fn session_record_context_export_binding_is_exact_without_identity() {
24097        let session = terminal_address(1);
24098        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24099        expected.state = ManagedSessionState::Live;
24100        expected.active_session = Some(session.clone());
24101        let binding = SessionBinding {
24102            workspace_id: session.workspace_id.clone(),
24103            generation: session.session.generation,
24104            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24105            pending_resume: None,
24106            record_id: Some(expected.record_id.clone()),
24107            managed_worktree_lease_id: None,
24108            environment_profile: None,
24109            bundle: None,
24110            context: None,
24111            materialization_id: None,
24112        };
24113        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24114        assert!(session_record_context_export_binding_is_exact(
24115            &expected,
24116            &expected,
24117            None,
24118            &session,
24119            &binding,
24120            &expected.provider,
24121            &current_root,
24122            false,
24123        ));
24124        // A record that gained an identity between the initial lookup and
24125        // this recheck no longer matches the `None` this shape expects to
24126        // keep seeing -- treated the same as any other mid-export drift.
24127        let mut drifted = expected.clone();
24128        drifted.provider_session = Some(ProviderSessionIdentity {
24129            key: ProviderSessionKey::SessionId,
24130            id: "unexpected-identity".to_owned(),
24131            transcript_path: None,
24132        });
24133        assert!(!session_record_context_export_binding_is_exact(
24134            &expected,
24135            &drifted,
24136            None,
24137            &session,
24138            &binding,
24139            &expected.provider,
24140            &current_root,
24141            false,
24142        ));
24143    }
24144
24145    #[test]
24146    fn session_record_context_export_preflight_requires_live_usable_source() {
24147        let mut source = record("claude", "record-context-export-preflight");
24148        source.state = ManagedSessionState::Live;
24149        assert!(session_record_context_export_source_is_usable(
24150            &source,
24151            &SessionStatus::Running,
24152            false,
24153        ));
24154        assert!(session_record_context_export_source_is_usable(
24155            &source,
24156            &SessionStatus::Exited { exit_code: Some(0) },
24157            false,
24158        ));
24159
24160        source.state = ManagedSessionState::Dormant;
24161        assert!(!session_record_context_export_source_is_usable(
24162            &source,
24163            &SessionStatus::Running,
24164            false,
24165        ));
24166        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24167        // the only caller that may treat a Dormant, cleanly-exited record as
24168        // still usable — the on-demand path above never does.
24169        assert!(session_record_context_export_source_is_usable(
24170            &source,
24171            &SessionStatus::Exited { exit_code: Some(0) },
24172            true,
24173        ));
24174        assert!(!session_record_context_export_source_is_usable(
24175            &source,
24176            &SessionStatus::Exited { exit_code: Some(0) },
24177            false,
24178        ));
24179        source.state = ManagedSessionState::Live;
24180        assert!(!session_record_context_export_source_is_usable(
24181            &source,
24182            &SessionStatus::Starting,
24183            false,
24184        ));
24185    }
24186
24187    #[test]
24188    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24189        let catalog = active_registry().unwrap();
24190        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24191        let workspace = WorkspaceConfig::new(
24192            WorkspaceId::new("primary").unwrap(),
24193            std::env::current_dir().unwrap(),
24194        )
24195        .unwrap();
24196        let shared = NodeShared::new(
24197            handle,
24198            "fixture-token".to_owned(),
24199            NodeId::new("node-context-export-conflict").unwrap(),
24200            vec![workspace],
24201            vec![agent("claude")],
24202        );
24203        let history = HistorySessionRecord {
24204            session_id: "provider-exact".to_owned(),
24205            title: None,
24206            cwd: None,
24207            model: None,
24208            message_count: 1,
24209            completed_turn_count: None,
24210            total_tokens: 0,
24211            messages: vec![gate4agent_types::HistoryMessageRecord {
24212                role: gate4agent_types::HistoryMessageRole::User,
24213                text: "bounded context".to_owned(),
24214            }],
24215        };
24216        let pack = NodeContextPack::export(
24217            ContextPackLineageReceipt {
24218                source_node_id: shared.node_id.clone(),
24219                source_session: terminal_address(1),
24220                source_provider: agent("claude"),
24221            },
24222            &history,
24223        )
24224        .unwrap();
24225        let context_id = pack.receipt().id.clone();
24226        let session = terminal_address(1);
24227        let identity = ProviderSessionIdentity {
24228            key: ProviderSessionKey::SessionId,
24229            id: history.session_id.clone(),
24230            transcript_path: None,
24231        };
24232        let mut expected = record("claude", "record-context-export-final-conflict");
24233        expected.state = ManagedSessionState::Live;
24234        expected.provider_session = Some(identity.clone());
24235        expected.active_session = Some(session.clone());
24236        let mut changed = expected.clone();
24237        changed.updated_at_unix_ms += 1;
24238        shared.insert_record(changed).unwrap();
24239
24240        let error = shared
24241            .commit_context_pack_for_session_record(
24242                &expected,
24243                Some(&identity),
24244                &session,
24245                "candidate-exact",
24246                &history,
24247                pack,
24248                false,
24249            )
24250            .unwrap_err();
24251
24252        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24253        assert!(shared
24254            .context_catalog
24255            .read()
24256            .unwrap_or_else(|poisoned| poisoned.into_inner())
24257            .get(&context_id)
24258            .is_none());
24259    }
24260
24261    #[tokio::test]
24262    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24263        let root = temporary_workspace_root("session-task-binding");
24264        std::fs::create_dir_all(&root).unwrap();
24265        let state_path = root.join("node-state.json");
24266        let catalog = active_registry().unwrap();
24267        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24268        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24269        let node_id = NodeId::new("node-1").unwrap();
24270        let mut shared = NodeShared::new(
24271            handle,
24272            "fixture-token".to_owned(),
24273            node_id.clone(),
24274            vec![workspace],
24275            vec![agent("claude")],
24276        );
24277        shared.state_path = Some(state_path.clone());
24278        let record = record("claude", "sr-task");
24279        let record_id = record.record_id.clone();
24280        shared.insert_record(record).unwrap();
24281        shared.persist_state().unwrap();
24282        shared
24283            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24284            .unwrap();
24285
24286        let minted = process_request_inner(
24287            &shared,
24288            77,
24289            ClientRole::Operator,
24290            NodeRequest::SetSessionTask {
24291                record_id: record_id.clone(),
24292                expected_revision: 0,
24293                target: SessionTaskTargetV1::New,
24294            },
24295        )
24296        .await
24297        .unwrap();
24298        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24299            panic!("session task mutation returned another response");
24300        };
24301        let minted_binding = minted.task_binding.clone().unwrap();
24302        let minted_task_id = minted_binding.task_id.clone().unwrap();
24303        assert_eq!(minted_binding.revision, 1);
24304        assert!(minted_task_id.as_str().starts_with("task-"));
24305        assert_eq!(minted_task_id.as_str().len(), 29);
24306
24307        let event_count = shared.current_sequence();
24308        let repeated = process_request_inner(
24309            &shared,
24310            77,
24311            ClientRole::Operator,
24312            NodeRequest::SetSessionTask {
24313                record_id: record_id.clone(),
24314                expected_revision: 1,
24315                target: SessionTaskTargetV1::Existing {
24316                    task_id: minted_task_id.clone(),
24317                },
24318            },
24319        )
24320        .await
24321        .unwrap();
24322        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24323        assert_eq!(shared.current_sequence(), event_count);
24324
24325        let stale = process_request_inner(
24326            &shared,
24327            77,
24328            ClientRole::Operator,
24329            NodeRequest::SetSessionTask {
24330                record_id: record_id.clone(),
24331                expected_revision: 0,
24332                target: SessionTaskTargetV1::Existing {
24333                    task_id: minted_task_id,
24334                },
24335            },
24336        )
24337        .await
24338        .unwrap_err();
24339        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24340
24341        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24342        let assigned = process_request_inner(
24343            &shared,
24344            77,
24345            ClientRole::Operator,
24346            NodeRequest::SetSessionTask {
24347                record_id: record_id.clone(),
24348                expected_revision: 1,
24349                target: SessionTaskTargetV1::Existing {
24350                    task_id: assigned_id.clone(),
24351                },
24352            },
24353        )
24354        .await
24355        .unwrap();
24356        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24357            panic!("session task assignment returned another response");
24358        };
24359        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24360        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24361
24362        let cleared = process_request_inner(
24363            &shared,
24364            77,
24365            ClientRole::Operator,
24366            NodeRequest::SetSessionTask {
24367                record_id: record_id.clone(),
24368                expected_revision: 2,
24369                target: SessionTaskTargetV1::Clear,
24370            },
24371        )
24372        .await
24373        .unwrap();
24374        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24375            panic!("session task clear returned another response");
24376        };
24377        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24378        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24379        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24380        assert_eq!(
24381            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24382            cleared.updated_at_unix_ms,
24383        );
24384
24385        let persisted = std::fs::read(&state_path).unwrap();
24386        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24387        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24388        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24389        std::fs::remove_dir_all(root).unwrap();
24390    }
24391
24392    #[test]
24393    fn session_task_capability_strips_snapshots_events_and_replies() {
24394        let mut bound = record("claude", "sr-task-projection");
24395        bound.updated_at_unix_ms = 2;
24396        bound.task_binding = Some(SessionTaskBindingV1 {
24397            revision: 1,
24398            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24399            changed_at_unix_ms: 2,
24400        });
24401        let request = NodeRequest::SetSessionTask {
24402            record_id: bound.record_id.clone(),
24403            expected_revision: 1,
24404            target: SessionTaskTargetV1::Clear,
24405        };
24406        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24407            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24408        }));
24409        assert!(!request_uses_unnegotiated_capability(
24410            &request,
24411            &baseline_capabilities().unwrap(),
24412        ));
24413        assert!(request_uses_unnegotiated_capability(&request, &[]));
24414
24415        let mut snapshot = terminal_test_shared().snapshot();
24416        snapshot.session_records.push(bound.clone());
24417        let mut reply = ResponseEnvelope {
24418            request_id: 1,
24419            result: Ok(NodeResponse::Resync {
24420                event_sequence: 1,
24421                oldest_available_sequence: 1,
24422                snapshot,
24423                events: vec![NodeEventEnvelope {
24424                    sequence: 1,
24425                    event: NodeEvent::SessionRecordUpserted {
24426                        record: bound.clone(),
24427                    },
24428                }],
24429            }),
24430        };
24431        project_response_without_session_task_binding(&mut reply);
24432        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24433            panic!("task projection changed the response kind");
24434        };
24435        assert!(snapshot.session_records[0].task_binding.is_none());
24436        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24437            panic!("task projection changed the event kind");
24438        };
24439        assert!(record.task_binding.is_none());
24440
24441        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24442            sequence: 2,
24443            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24444        });
24445        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24446            panic!("task event projection changed the event kind");
24447        };
24448        assert!(record.task_binding.is_none());
24449
24450        let mut direct = ResponseEnvelope {
24451            request_id: 2,
24452            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24453        };
24454        project_response_without_session_task_binding(&mut direct);
24455        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24456            panic!("task reply projection changed the response kind");
24457        };
24458        assert!(record.task_binding.is_none());
24459    }
24460
24461    #[test]
24462    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24463        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24464        let workspace = WorkspaceConfig::new(
24465            WorkspaceId::new("primary").unwrap(),
24466            std::env::current_dir().unwrap(),
24467        )
24468        .unwrap();
24469        let config = NodeServerConfig::new(
24470            DEFAULT_NODE_ENDPOINT,
24471            "fixture-token",
24472            NodeId::new("node-1").unwrap(),
24473            [workspace],
24474        )
24475        .unwrap();
24476        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24477    }
24478
24479    #[cfg(all(feature = "fixture", windows))]
24480    #[test]
24481    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24482        let root = temporary_workspace_root("provider-bundle-argv-hold");
24483        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24484        std::fs::create_dir_all(&root).unwrap();
24485        std::fs::create_dir_all(&outside).unwrap();
24486        let proof_path = root.join("provider.proof");
24487        let release_signal = root.join("release.signal");
24488        let config = NodeServerConfig::new(
24489            format!(
24490                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24491                std::process::id(),
24492            ),
24493            "fixture-token",
24494            NodeId::new("provider-bundle-hold-node").unwrap(),
24495            [WorkspaceConfig::new(
24496                WorkspaceId::new("primary").unwrap(),
24497                std::env::current_dir().unwrap(),
24498            )
24499            .unwrap()],
24500        )
24501        .unwrap()
24502        .with_spawn_profiles(SpawnProfileRegistry::new([
24503            crate::protocol::SpawnProfileDefaults {
24504                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24505                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24506                    .unwrap(),
24507                provider: agent("kimi"),
24508                mode: SessionMode::Pty,
24509                terminal_size: gate4agent_types::TerminalSize {
24510                    rows: 24,
24511                    columns: 80,
24512                },
24513                prompt: None,
24514                bundle_id: None,
24515                context_id: None,
24516                environment_profile_id: None,
24517            },
24518        ])
24519        .unwrap());
24520        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24521            config,
24522            agent("kimi"),
24523            proof_path.clone(),
24524            release_signal.clone(),
24525        )
24526        .unwrap();
24527        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24528
24529        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24530            agent("kimi"),
24531            proof_path.clone(),
24532            Some(release_signal.clone()),
24533        )
24534        .unwrap();
24535        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24536        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24537        let release_wait = wrapper
24538            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24539            .unwrap();
24540        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24541        assert!(proof_write < release_wait);
24542        assert!(release_wait < fixture_ready);
24543        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24544        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24545        assert_eq!(
24546            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24547            &[
24548                proof_path.to_string_lossy().into_owned(),
24549                release_signal.to_string_lossy().into_owned(),
24550            ],
24551        );
24552
24553        let error = NodeServer::provider_bundle_argv_fixture_spec(
24554            agent("kimi"),
24555            proof_path,
24556            Some(outside.join("release.signal")),
24557        )
24558        .unwrap_err();
24559        assert!(matches!(
24560            error,
24561            NodeServerError::Registry(message)
24562                if message == "provider bundle argv release path must share the proof parent"
24563        ));
24564        drop(server);
24565        std::fs::remove_dir_all(root).unwrap();
24566        std::fs::remove_dir_all(outside).unwrap();
24567    }
24568
24569    #[cfg(feature = "fixture")]
24570    #[test]
24571    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24572        let root = temporary_workspace_root("context-fixture-registry");
24573        std::fs::create_dir_all(&root).unwrap();
24574        let proof_path = root.join("context.proof");
24575        let config = NodeServerConfig::new(
24576            format!(
24577                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24578                std::process::id(),
24579            ),
24580            "fixture-token",
24581            NodeId::new("context-fixture-node").unwrap(),
24582            [WorkspaceConfig::new(
24583                WorkspaceId::new("primary").unwrap(),
24584                std::env::current_dir().unwrap(),
24585            )
24586            .unwrap()],
24587        )
24588        .unwrap();
24589        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24590        let mut enabled = server
24591            .shared
24592            .enabled_providers
24593            .iter()
24594            .map(AgentId::as_str)
24595            .collect::<Vec<_>>();
24596        enabled.sort_unstable();
24597        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24598        for provider in &server.shared.enabled_providers {
24599            assert!(server
24600                .shared
24601                .provider_adapter_contracts
24602                .iter()
24603                .any(|contract| {
24604                    &contract.provider == provider
24605                        && contract.family == AdapterFamily::History
24606                }));
24607        }
24608        drop(server);
24609        std::fs::remove_dir_all(root).unwrap();
24610    }
24611
24612    #[cfg(feature = "fixture")]
24613    #[test]
24614    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24615        let root = temporary_workspace_root("monitoring-context-fixture");
24616        std::fs::create_dir_all(&root).unwrap();
24617        let proof_path = root.join("context.proof");
24618        let standard = NodeServer::context_pack_fixture_catalog(
24619            proof_path.clone(),
24620            None,
24621            false,
24622        ).unwrap();
24623        let monitoring = NodeServer::context_pack_fixture_catalog(
24624            proof_path.clone(),
24625            None,
24626            true,
24627        ).unwrap();
24628        let claude = AgentId::new("claude").unwrap();
24629        let standard_claude = standard.get(&claude).unwrap();
24630        let monitoring_claude = monitoring.get(&claude).unwrap();
24631        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24632        // standard nor the monitoring fixture declares a hook adapter
24633        // anymore.
24634        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24635        assert_eq!(
24636            monitoring_claude.capabilities.adapters.history,
24637            standard_claude.capabilities.adapters.history,
24638        );
24639        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24640        let codex = AgentId::new("codex").unwrap();
24641        let standard_codex = standard.get(&codex).unwrap();
24642        let monitoring_codex = monitoring.get(&codex).unwrap();
24643        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24644        assert_eq!(
24645            monitoring_codex.capabilities.adapters.history,
24646            standard_codex.capabilities.adapters.history,
24647        );
24648        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24649        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24650            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24651            .unwrap();
24652        for required in [
24653            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24654            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24655            "@('role', 'text')",
24656            "g4a-private-provider-session-canary",
24657            "private-provider-login-identity",
24658            "$rawProof",
24659            "$env:GATE4AGENT_HOOK_URL",
24660            "g4a-private-provider-session-canary",
24661        ] {
24662            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24663        }
24664        for provider in ["grok", "kimi"] {
24665            let provider = AgentId::new(provider).unwrap();
24666            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24667        }
24668
24669        let config = NodeServerConfig::new(
24670            format!(
24671                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24672                std::process::id(),
24673            ),
24674            "fixture-token",
24675            NodeId::new("monitoring-context-fixture-node").unwrap(),
24676            [WorkspaceConfig::new(
24677                WorkspaceId::new("primary").unwrap(),
24678                std::env::current_dir().unwrap(),
24679            )
24680            .unwrap()],
24681        )
24682        .unwrap();
24683        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24684        assert!(server.shared.fixture_semantic_hook_policy);
24685        drop(server);
24686        std::fs::remove_dir_all(root).unwrap();
24687    }
24688
24689    #[cfg(feature = "fixture")]
24690    #[test]
24691    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24692        let root = temporary_workspace_root("kimi-context-only-fixture");
24693        std::fs::create_dir_all(&root).unwrap();
24694        let proof_path = root.join("context.proof");
24695        let standard = NodeServer::context_pack_fixture_catalog(
24696            proof_path.clone(),
24697            None,
24698            false,
24699        ).unwrap();
24700        let kimi = AgentId::new("kimi").unwrap();
24701        let context_only = NodeServer::context_pack_fixture_catalog(
24702            proof_path,
24703            Some(&kimi),
24704            false,
24705        )
24706        .unwrap();
24707
24708        let codex = AgentId::new("codex").unwrap();
24709        assert_eq!(
24710            standard.get(&codex).unwrap().launch.fixed_args,
24711            context_only.get(&codex).unwrap().launch.fixed_args,
24712        );
24713        let script = context_only
24714            .get(&kimi)
24715            .unwrap()
24716            .launch
24717            .fixed_args
24718            .iter()
24719            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24720            .expect("Kimi context-only validation script is missing");
24721        for required in [
24722            "$bundleArgs.Count -ne 0",
24723            "GATE4AGENT_CONTEXT_ROOT",
24724            "context-pack.json",
24725            "$entries.Count -ne 1",
24726            "g4a-context-pack-v1",
24727            "$document.PSObject.Properties['cwd']",
24728            "'claude', 'codex', 'grok', 'kimi'",
24729            "$hasUser",
24730            "$hasAssistant",
24731            "SHA256",
24732            "context-only",
24733        ] {
24734            assert!(script.contains(required), "missing validation: {required}");
24735        }
24736        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24737            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24738        }
24739        assert!(!standard
24740            .get(&kimi)
24741            .unwrap()
24742            .launch
24743            .fixed_args
24744            .iter()
24745            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24746
24747        std::fs::remove_dir_all(root).unwrap();
24748    }
24749
24750    #[cfg(all(feature = "fixture", windows))]
24751    #[tokio::test]
24752    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24753        let root = temporary_workspace_root("kimi-context-only-spawn");
24754        let workspace_root = root.join("workspace");
24755        std::fs::create_dir_all(&workspace_root).unwrap();
24756        let materialization_root = root.join("private-materializations");
24757        let proof_path = root.join("context.proof");
24758        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24759        let workspace_id = WorkspaceId::new("primary").unwrap();
24760        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24761        let endpoint = format!(
24762            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24763            std::process::id(),
24764            unix_time_ms(),
24765        );
24766        let spawn_profiles = SpawnProfileRegistry::new([
24767            crate::protocol::SpawnProfileDefaults {
24768                profile_id: profile_id.clone(),
24769                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24770                provider: agent("kimi"),
24771                mode: SessionMode::Pty,
24772                terminal_size: gate4agent_types::TerminalSize {
24773                    rows: 30,
24774                    columns: 120,
24775                },
24776                prompt: None,
24777                bundle_id: None,
24778                context_id: None,
24779                environment_profile_id: None,
24780            },
24781        ])
24782        .unwrap();
24783        let mut config = NodeServerConfig::new(
24784            &endpoint,
24785            "fixture-token",
24786            node_id.clone(),
24787            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24788        )
24789        .unwrap()
24790        .with_spawn_profiles(spawn_profiles)
24791        .with_session_environment_materialization(
24792            materialization_root,
24793            Arc::new(FixtureSecretResolver {
24794                deny: Arc::new(AtomicBool::new(false)),
24795            }),
24796        )
24797        .unwrap();
24798        config.fixture_raw_pty_runtime = true;
24799        let server = NodeServer::new_context_only_proof_fixture(
24800            config,
24801            agent("kimi"),
24802            proof_path.clone(),
24803        )
24804        .unwrap();
24805        let shared = Arc::clone(&server.shared);
24806        let shutdown = server.shutdown_handle();
24807
24808        let history = HistorySessionRecord {
24809            session_id: "context-only-source-history".to_owned(),
24810            title: None,
24811            cwd: None,
24812            model: None,
24813            message_count: 2,
24814            completed_turn_count: None,
24815            total_tokens: 0,
24816            messages: vec![
24817                gate4agent_types::HistoryMessageRecord {
24818                    role: gate4agent_types::HistoryMessageRole::User,
24819                    text: "continue the bounded parent run through native C2".to_owned(),
24820                },
24821                gate4agent_types::HistoryMessageRecord {
24822                    role: gate4agent_types::HistoryMessageRole::Assistant,
24823                    text: "the exact parent context is ready for the child run".to_owned(),
24824                },
24825            ],
24826        };
24827        let pack = NodeContextPack::export(
24828            ContextPackLineageReceipt {
24829                source_node_id: node_id.clone(),
24830                source_session: SessionAddress {
24831                    workspace_id: workspace_id.clone(),
24832                    session: SessionKey {
24833                        instance_id: AgentInstanceId(71),
24834                        generation: SessionGeneration(1),
24835                    },
24836                },
24837                source_provider: agent("codex"),
24838            },
24839            &history,
24840        )
24841        .unwrap();
24842        let expected_context_hash = digest(&SHA256, pack.bytes())
24843            .as_ref()
24844            .iter()
24845            .map(|byte| format!("{byte:02x}"))
24846            .collect::<String>();
24847        let context = shared
24848            .context_catalog
24849            .write()
24850            .unwrap_or_else(|poisoned| poisoned.into_inner())
24851            .insert(pack)
24852            .unwrap();
24853        let spec = SpawnSpec {
24854            target: crate::protocol::SpawnTarget {
24855                node_id,
24856                workspace_id: workspace_id.clone(),
24857                worktree_id: None,
24858            },
24859            profile_id,
24860            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24861                "target-kimi-r1",
24862            ).unwrap(),
24863            overrides: crate::protocol::SpawnOverrides {
24864                context_id: crate::protocol::SpawnOverride::Set {
24865                    value: context.id.clone(),
24866                },
24867                ..crate::protocol::SpawnOverrides::default()
24868            },
24869            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24870            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24871            required_capabilities: SpawnRequiredCapabilities::default(),
24872        };
24873        let resolved = match shared.resolve_spawn_spec(&spec) {
24874            Ok(resolved) => resolved,
24875            Err(error) => panic!(
24876                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24877                error.code,
24878                error.message,
24879            ),
24880        };
24881        let resolved_context = match shared.resolve_context(&resolved) {
24882            Ok(Some(resolved_context)) => resolved_context,
24883            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24884            Err(error) => panic!(
24885                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24886                error.code,
24887                error.message,
24888            ),
24889        };
24890        assert_eq!(resolved_context, context);
24891        let runtime_policy = match shared
24892            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24893            .await
24894        {
24895            Ok(runtime_policy) => runtime_policy,
24896            Err(error) => panic!(
24897                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24898                error.code,
24899                error.message,
24900            ),
24901        };
24902        assert!(runtime_policy.raw_pty_lifecycle);
24903
24904        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24905        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24906            agent("kimi"),
24907            TransportKind::Pty,
24908            vec![EnvMutation {
24909                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24910                value: Some(OsString::from("must-remain-rejected")),
24911            }],
24912        )
24913        .unwrap();
24914        assert_eq!(
24915            launch_control
24916                .install_native_launch_environment_overlay(
24917                    AgentInstanceId(72),
24918                    generic_overlay,
24919                )
24920                .unwrap_err(),
24921            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24922        );
24923
24924        let diagnostic_address = SessionAddress {
24925            workspace_id: workspace_id.clone(),
24926            session: SessionKey {
24927                instance_id: AgentInstanceId(73),
24928                generation: SessionGeneration(1),
24929            },
24930        };
24931        let (diagnostic_overlay, diagnostic_materialization) = match shared
24932            .prepare_session_materialization(
24933                &diagnostic_address,
24934                &agent("kimi"),
24935                SessionMode::Pty,
24936                None,
24937                None,
24938                Some(&resolved_context),
24939                None,
24940            )
24941        {
24942            Ok(Some(prepared)) => prepared,
24943            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24944            Err(error) => panic!(
24945                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24946                error.code,
24947                error.message,
24948            ),
24949        };
24950        let diagnostic_overlay = diagnostic_overlay
24951            .expect("Kimi context-only materialization omitted its environment overlay");
24952        assert!(matches!(
24953            &diagnostic_overlay,
24954            PreparedNativeLaunchOverlay::Environment(_),
24955        ));
24956        match shared.install_prepared_launch_overlay(
24957            diagnostic_address.session.instance_id,
24958            diagnostic_overlay,
24959        ) {
24960            Ok(None) => {}
24961            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24962            Err(error) => panic!(
24963                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24964                error.code,
24965                error.message,
24966            ),
24967        }
24968        assert!(launch_control.clear_native_instance_launch_overlay(
24969            diagnostic_address.session.instance_id,
24970        ));
24971        drop(diagnostic_materialization);
24972        assert!(shared
24973            .materializations
24974            .lock()
24975            .unwrap_or_else(|poisoned| poisoned.into_inner())
24976            .is_empty());
24977
24978        let node_task = tokio::spawn(server.run());
24979        let ready_client = timeout(Duration::from_secs(5), async {
24980            loop {
24981                match gate4agent_node_wire::LocalNodeClient::connect(
24982                    &endpoint,
24983                    &shared.node_id,
24984                    ClientRole::Observer,
24985                    "fixture-token",
24986                )
24987                .await
24988                {
24989                    Ok(client) => break client,
24990                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24991                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24992                }
24993            }
24994        })
24995        .await
24996        .expect("Kimi context-only Node did not become ready");
24997        assert_eq!(
24998            ready_client
24999                .hello()
25000                .snapshot
25001                .provider_runtime_statuses
25002                .iter()
25003                .find(|status| status.provider().as_str() == "kimi")
25004                .expect("Kimi runtime status is missing")
25005                .mode(),
25006            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25007        );
25008        drop(ready_client);
25009
25010        let receipt = match shared.spawn_from_spec(spec).await {
25011            Ok(receipt) => receipt,
25012            Err(error) => panic!(
25013                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25014                error.code,
25015                error.message,
25016            ),
25017        };
25018        assert_eq!(receipt.provider, agent("kimi"));
25019        assert_eq!(receipt.context.as_ref(), Some(&context));
25020        assert!(receipt.context_binding_is_valid());
25021        timeout(Duration::from_secs(5), async {
25022            while !proof_path.is_file() {
25023                sleep(Duration::from_millis(10)).await;
25024            }
25025        })
25026        .await
25027        .expect("Kimi context-only fixture did not write its proof");
25028
25029        let proof = std::fs::read_to_string(&proof_path).unwrap();
25030        let lines = proof.lines().collect::<Vec<_>>();
25031        assert_eq!(lines.len(), 7);
25032        assert_eq!(lines[0], "context-only");
25033        assert!(Path::new(lines[1]).is_absolute());
25034        assert_ne!(
25035            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25036            std::fs::canonicalize(&workspace_root).unwrap(),
25037        );
25038        assert_eq!(
25039            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25040            std::fs::canonicalize(&workspace_root).unwrap(),
25041        );
25042        assert_eq!(lines[3], expected_context_hash);
25043        assert_eq!(lines[4], "g4a-context-pack-v1");
25044        assert_eq!(lines[5], "codex");
25045        assert_eq!(lines[6], "2");
25046        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25047            session.instance_id == receipt.session.session.instance_id
25048                && session.agent_id.as_str() == "kimi"
25049        }));
25050
25051        shutdown.request_shutdown().await.unwrap();
25052        timeout(Duration::from_secs(5), node_task)
25053            .await
25054            .unwrap()
25055            .unwrap()
25056            .unwrap();
25057        drop(shutdown);
25058        drop(shared);
25059        std::fs::remove_dir_all(root).unwrap();
25060    }
25061
25062    #[cfg(feature = "fixture")]
25063    #[test]
25064    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25065        let root = temporary_workspace_root("clean-exit-constructor");
25066        std::fs::create_dir_all(&root).unwrap();
25067        let config = construction_test_config("clean-exit-constructor")
25068            .with_state_path(root.join("node-state.json"))
25069            .unwrap();
25070        let server = NodeServer::new_clean_exit_fixture(
25071            config,
25072            root.clone(),
25073            root.join("started.marker"),
25074            root.join("release.signal"),
25075        )
25076        .unwrap();
25077        assert_eq!(
25078            server
25079                .shared
25080                .enabled_providers
25081                .iter()
25082                .map(AgentId::as_str)
25083                .collect::<Vec<_>>(),
25084            ["claude"],
25085        );
25086        drop(server);
25087        std::fs::remove_dir_all(root).unwrap();
25088    }
25089
25090    #[test]
25091    fn production_registry_provider_contract_manifest_is_exact() {
25092        let registry = active_registry().unwrap();
25093        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25094        assert_eq!(
25095            providers
25096                .iter()
25097                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25098                .collect::<Vec<_>>(),
25099            vec![
25100                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25101                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25102                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25103                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25104            ]
25105        );
25106        assert_eq!(
25107            adapters
25108                .iter()
25109                .map(|contract| (
25110                    contract.provider.as_str(),
25111                    contract.family,
25112                    contract.adapter_id.as_str(),
25113                    contract.revision.as_str(),
25114                ))
25115                .collect::<Vec<_>>(),
25116            vec![
25117                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25118                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25119                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25120                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25121                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25122                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25123                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25124                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25125                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25126                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25127                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25128                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25129                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25130                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25131                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25132                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25133                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25134                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25135                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25136                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25137                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25138                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25139                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25140            ]
25141        );
25142    }
25143
25144    fn construction_test_config(name: &str) -> NodeServerConfig {
25145        let workspace = WorkspaceConfig::new(
25146            WorkspaceId::new("primary").unwrap(),
25147            std::env::current_dir().unwrap(),
25148        )
25149        .unwrap();
25150        NodeServerConfig::new(
25151            format!(r"\\.\pipe\gate4agent-{name}"),
25152            "fixture-token",
25153            NodeId::new(name).unwrap(),
25154            [workspace],
25155        )
25156        .unwrap()
25157    }
25158
25159    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25160        let adapter = AdapterBinding::new(
25161            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25162            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25163            gate4agent_types::AdapterVerification::SyntheticFixture,
25164        )
25165        .unwrap();
25166        let agent_ids = ["claude", "codex", "kimi"]
25167            .into_iter()
25168            .map(|agent_id| AgentId::new(agent_id).unwrap())
25169            .collect::<Vec<_>>();
25170        let adapters = gate4agent_catalog::AdapterRegistry::new(
25171            [
25172                AdapterFamily::PtySemantic,
25173                AdapterFamily::Pipe,
25174                AdapterFamily::OneShot,
25175                AdapterFamily::Acp,
25176                AdapterFamily::Hook,
25177                AdapterFamily::ManagedHook,
25178                AdapterFamily::History,
25179                AdapterFamily::Resume,
25180                AdapterFamily::SessionOptions,
25181                AdapterFamily::CapabilityProbe,
25182            ]
25183            .into_iter()
25184            .map(|family| gate4agent_catalog::AdapterDescriptor {
25185                family,
25186                binding: adapter.clone(),
25187                agents: agent_ids.clone(),
25188            }),
25189        )
25190        .unwrap();
25191        let specs = agent_ids
25192            .iter()
25193            .map(|agent_id| {
25194                let agent_id = agent_id.as_str();
25195                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25196                spec.revision =
25197                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25198                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25199                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25200                    adapter: adapter.clone(),
25201                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25202                    launch_override: None,
25203                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25204                });
25205                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25206                    adapter: adapter.clone(),
25207                    launch_override: None,
25208                });
25209                spec.capabilities.adapters.hook = Some(adapter.clone());
25210                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25211                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25212                spec.capabilities.adapters.history = Some(adapter.clone());
25213                spec.capabilities.adapters.resume = Some(adapter.clone());
25214                spec.capabilities.adapters.session_options = Some(adapter.clone());
25215                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25216                spec
25217            })
25218            .collect::<Vec<_>>();
25219        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25220    }
25221
25222    /// The real fleet's manifest is accepted by a real server construction.
25223    ///
25224    /// The count is asserted against `active_registry` itself rather than a
25225    /// literal, so this stays a test that construction succeeds for whatever
25226    /// the fleet is -- which is the property worth guarding -- instead of a
25227    /// second place the fleet size is written down and has to be kept in
25228    /// step with the first.
25229    #[test]
25230    fn node_server_construction_accepts_the_production_manifest() {
25231        let registry = active_registry().unwrap();
25232        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25233        assert_eq!(providers.len(), registry.iter().count());
25234        assert!(!providers.is_empty());
25235        let server = NodeServer::new_with_registry(
25236            construction_test_config("manifest-production-bound"),
25237            registry,
25238        )
25239        .unwrap();
25240        drop(server);
25241    }
25242
25243    #[test]
25244    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25245        let registry = maximum_size_provider_manifest_registry();
25246        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25247        assert_eq!(providers.len(), 3);
25248        assert_eq!(adapters.len(), 30);
25249        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25250        let error = match NodeServer::new_with_registry(
25251            construction_test_config("manifest-handshake-overflow"),
25252            registry,
25253        ) {
25254            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25255            Err(error) => error,
25256        };
25257        assert!(matches!(
25258            error,
25259            NodeServerError::ProviderContractManifest(message)
25260                if message.contains("authentication binding length")
25261                    && message.contains("8192-byte limit")
25262        ));
25263    }
25264
25265    #[test]
25266    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25267        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25268        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25269        let registry = AgentRegistry::new([spec]).unwrap();
25270        assert!(matches!(
25271            provider_contract_manifest(&registry),
25272            Err(NodeServerError::ProviderContractManifest(message))
25273                if message.contains("exceeds the 128-byte limit")
25274        ));
25275    }
25276
25277    #[test]
25278    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25279        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25280        spec.revision = "invalid revision".to_owned();
25281        let registry = AgentRegistry::new([spec]).unwrap();
25282        assert!(matches!(
25283            provider_contract_manifest(&registry),
25284            Err(NodeServerError::ProviderContractManifest(message))
25285                if message.contains("bounded lowercase ASCII")
25286        ));
25287    }
25288
25289    #[test]
25290    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25291        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25292        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25293        let conflicting_binding = builtin_registry()
25294            .get_by_id("codex")
25295            .unwrap()
25296            .capabilities
25297            .adapters
25298            .one_shot
25299            .clone()
25300            .unwrap();
25301        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25302        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25303        pipe.adapter = transport_binding;
25304        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25305        let registry = AgentRegistry::new([spec]).unwrap();
25306        assert!(matches!(
25307            provider_contract_manifest(&registry),
25308            Err(NodeServerError::ProviderContractManifest(message))
25309                if message.contains("conflicting OneShot adapter bindings")
25310        ));
25311    }
25312
25313    #[test]
25314    fn public_node_failures_expose_only_fixed_categories() {
25315        let provider = failure(
25316            NodeFailureCode::BackendOperationFailed,
25317            r"provider bearer-secret failed at C:\private\session.jsonl",
25318        );
25319        assert_eq!(provider.message, "backend-operation-failed");
25320        assert!(!provider.message.contains("bearer-secret"));
25321        assert!(!provider.message.contains(r"C:\private"));
25322        let persistence = persistence_failure(io::Error::new(
25323            io::ErrorKind::Other,
25324            r"state write failed at C:\private\state-v1.json",
25325        ));
25326        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25327        assert!(!persistence.message.contains(r"C:\private"));
25328
25329        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25330            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25331            assert_eq!(
25332                generic.to_string(),
25333                "node durable state failed: durable-state-load-failed",
25334            );
25335        }
25336    }
25337
25338    #[test]
25339    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25340        let sequence = b"\x1b[1;5D".to_vec();
25341        assert_eq!(
25342            terminal_bytes_action(sequence.clone()).unwrap(),
25343            InputAction::TerminalBytes(sequence),
25344        );
25345        assert_eq!(
25346            terminal_bytes_action(Vec::new()).unwrap_err().code,
25347            NodeFailureCode::InvalidRequest,
25348        );
25349        assert_eq!(
25350            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25351                .unwrap_err()
25352                .code,
25353            NodeFailureCode::InvalidRequest,
25354        );
25355    }
25356
25357    #[test]
25358    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25359        let root = temporary_workspace_root("exclusive-state-owner");
25360        std::fs::create_dir_all(&root).unwrap();
25361        let workspace = WorkspaceConfig::new(
25362            WorkspaceId::new("primary").unwrap(),
25363            &root,
25364        )
25365        .unwrap();
25366        let state_path = root.join("state-v1.json");
25367        let endpoint = format!(
25368            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25369            std::process::id(),
25370            std::time::SystemTime::now()
25371                .duration_since(std::time::UNIX_EPOCH)
25372                .unwrap()
25373                .as_nanos(),
25374        );
25375        let config = NodeServerConfig::new(
25376            endpoint,
25377            "fixture-token",
25378            NodeId::new("node-1").unwrap(),
25379            [workspace],
25380        )
25381        .unwrap()
25382        .with_state_path(&state_path)
25383        .unwrap();
25384
25385        let first = NodeServer::new(config.clone()).unwrap();
25386        let error = match NodeServer::new(config.clone()) {
25387            Ok(_) => panic!("a second node server acquired the same durable state path"),
25388            Err(error) => error,
25389        };
25390        assert!(matches!(
25391            &error,
25392            NodeServerError::DurableState(error)
25393                if error.kind() == io::ErrorKind::WouldBlock
25394        ));
25395        assert_eq!(
25396            error.to_string(),
25397            "node durable state failed: durable-state-lock-failed",
25398        );
25399        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25400        drop(first);
25401        let second = NodeServer::new(config).unwrap();
25402        drop(second);
25403        std::fs::remove_dir_all(root).unwrap();
25404    }
25405
25406    #[test]
25407    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25408        let catalog = active_registry().unwrap();
25409        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25410        let workspace_id = WorkspaceId::new("primary").unwrap();
25411        let workspace = WorkspaceConfig::new(
25412            workspace_id.clone(),
25413            std::env::current_dir().unwrap(),
25414        )
25415        .unwrap();
25416        let shared = NodeShared::new(
25417            handle,
25418            "fixture-token".to_owned(),
25419            NodeId::new("node-1").unwrap(),
25420            vec![workspace.clone()],
25421            vec![agent("claude"), agent("codex"), agent("kimi")],
25422        );
25423        let instance_id = AgentInstanceId(17);
25424        let generation = SessionGeneration(3);
25425        assert!(shared.address_for(instance_id, generation).is_none());
25426        shared.bind_session(instance_id, workspace_id.clone(), generation);
25427        let address = SessionAddress {
25428            workspace_id,
25429            session: SessionKey { instance_id, generation },
25430        };
25431        assert_eq!(
25432            shared.workspace_root(&address.workspace_id).unwrap(),
25433            workspace.canonical_root,
25434        );
25435        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25436        shared.remove_binding(&address);
25437        assert!(shared.address_for(instance_id, generation).is_none());
25438    }
25439
25440    #[test]
25441    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25442        let address = SessionAddress {
25443            workspace_id: WorkspaceId::new("primary").unwrap(),
25444            session: SessionKey {
25445                instance_id: AgentInstanceId(7),
25446                generation: SessionGeneration(2),
25447            },
25448        };
25449        let record_id = SessionRecordId::new("record-a").unwrap();
25450        let size = gate4agent_types::TerminalSize {
25451            rows: 24,
25452            columns: 80,
25453        };
25454        let address_requests = [
25455            NodeRequest::Resume {
25456                session: address.clone(),
25457                terminal_size: size,
25458                initial_prompt: None,
25459            },
25460            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25461            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25462            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25463            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25464            NodeRequest::TerminalControl {
25465                session: address.clone(),
25466                control: TerminalControl::Enter,
25467            },
25468            NodeRequest::Resize { session: address.clone(), size },
25469            NodeRequest::Interrupt { session: address.clone() },
25470            NodeRequest::Stop { session: address.clone(), force: false },
25471            NodeRequest::Remove { session: address },
25472        ];
25473        for request in &address_requests {
25474            assert!(request_requires_open_provider_ids_with(
25475                request,
25476                |_| Some(agent("third-party-agent")),
25477                |_| Some(agent("claude")),
25478            ));
25479            assert!(!request_requires_open_provider_ids_with(
25480                request,
25481                |_| Some(agent("claude")),
25482                |_| Some(agent("claude")),
25483            ));
25484            assert!(request_requires_open_provider_ids_with(
25485                request,
25486                |_| None,
25487                |_| Some(agent("claude")),
25488            ));
25489        }
25490
25491        let record_requests = [
25492            NodeRequest::RenameSessionRecord {
25493                record_id: record_id.clone(),
25494                display_name: "renamed".to_owned(),
25495            },
25496            NodeRequest::ResumeSessionRecord {
25497                record_id: record_id.clone(),
25498                terminal_size: size,
25499                initial_prompt: None,
25500            },
25501            NodeRequest::ForgetSessionRecord { record_id },
25502        ];
25503        for request in &record_requests {
25504            assert!(request_requires_open_provider_ids_with(
25505                request,
25506                |_| Some(agent("claude")),
25507                |_| Some(agent("grok")),
25508            ));
25509            assert!(!request_requires_open_provider_ids_with(
25510                request,
25511                |_| Some(agent("claude")),
25512                |_| Some(agent("kimi")),
25513            ));
25514            assert!(request_requires_open_provider_ids_with(
25515                request,
25516                |_| Some(agent("claude")),
25517                |_| None,
25518            ));
25519        }
25520
25521        assert!(request_requires_open_provider_ids_with(
25522            &NodeRequest::Spawn {
25523                workspace_id: WorkspaceId::new("primary").unwrap(),
25524                provider: agent("third-party-agent"),
25525                mode: SessionMode::Pty,
25526                terminal_size: size,
25527                initial_prompt: None,
25528            },
25529            |_| None,
25530            |_| None,
25531        ));
25532        assert!(!request_requires_open_provider_ids_with(
25533            &NodeRequest::Snapshot,
25534            |_| None,
25535            |_| None,
25536        ));
25537        assert!(request_requires_open_provider_ids_with(
25538            &NodeRequest::ForgetContextPack {
25539                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25540            },
25541            |_| None,
25542            |_| None,
25543        ));
25544    }
25545
25546    #[test]
25547    fn legacy_projection_filters_open_record_replies_and_event_history() {
25548        let catalog = active_registry().unwrap();
25549        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25550        let workspace = WorkspaceConfig::new(
25551            WorkspaceId::new("primary").unwrap(),
25552            std::env::current_dir().unwrap(),
25553        )
25554        .unwrap();
25555        let shared = NodeShared::new(
25556            handle,
25557            "fixture-token".to_owned(),
25558            NodeId::new("node-1").unwrap(),
25559            vec![workspace],
25560            vec![agent("claude"), agent("grok")],
25561        );
25562        let legacy = record("claude", "legacy-record");
25563        let open = record("grok", "open-record");
25564        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25565            record: legacy.clone(),
25566        });
25567        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25568            record: open.clone(),
25569        });
25570        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25571            record_id: legacy.record_id.clone(),
25572        });
25573        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25574            record_id: open.record_id.clone(),
25575        });
25576        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25577        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25578        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25579        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25580
25581        let mut record_reply = ResponseEnvelope {
25582            request_id: 1,
25583            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25584        };
25585        project_response_legacy_provider_ids(&shared, &mut record_reply);
25586        assert!(matches!(
25587            record_reply.result,
25588            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25589        ));
25590
25591        let mut resync = ResponseEnvelope {
25592            request_id: 2,
25593            result: Ok(NodeResponse::Resync {
25594                event_sequence: open_removed.sequence,
25595                oldest_available_sequence: 1,
25596                snapshot: shared.snapshot(),
25597                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25598            }),
25599        };
25600        project_response_legacy_provider_ids(&shared, &mut resync);
25601        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25602            panic!("legacy projection changed the resync response kind");
25603        };
25604        assert!(snapshot.session_records.iter().all(|item| {
25605            provider_id_is_legacy(&item.provider)
25606        }));
25607        assert_eq!(events.len(), 2);
25608        assert!(events.iter().all(|event| matches!(
25609            &event.event,
25610            NodeEvent::SessionRecordUpserted { record }
25611                if provider_id_is_legacy(&record.provider)
25612        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25613    }
25614
25615    #[test]
25616    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25617        let catalog = active_registry().unwrap();
25618        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25619        let workspace_id = WorkspaceId::new("primary").unwrap();
25620        let workspace = WorkspaceConfig::new(
25621            workspace_id.clone(),
25622            std::env::current_dir().unwrap(),
25623        )
25624        .unwrap();
25625        let shared = NodeShared::new(
25626            handle,
25627            "fixture-token".to_owned(),
25628            NodeId::new("node-1").unwrap(),
25629            vec![workspace],
25630            vec![agent("claude")],
25631        );
25632        for index in 0..CONTROL_SESSIONS_MAX {
25633            shared.bind_session(
25634                AgentInstanceId((index + 1) as u64),
25635                workspace_id.clone(),
25636                SessionGeneration(1),
25637            );
25638        }
25639        let error = shared.ensure_binding_capacity().unwrap_err();
25640        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25641    }
25642
25643    #[tokio::test]
25644    async fn provider_spawn_admission_rejects_before_session_mutation() {
25645        let catalog = active_registry().unwrap();
25646        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25647        let workspace = WorkspaceConfig::new(
25648            WorkspaceId::new("primary").unwrap(),
25649            std::env::current_dir().unwrap(),
25650        )
25651        .unwrap();
25652        let mut shared = NodeShared::new(
25653            handle,
25654            "fixture-token".to_owned(),
25655            NodeId::new("node-provider-admission").unwrap(),
25656            vec![workspace],
25657            vec![agent("claude")],
25658        );
25659        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25660            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25661        ])
25662        .unwrap();
25663        let terminal_size = gate4agent_types::TerminalSize {
25664            rows: 24,
25665            columns: 80,
25666        };
25667
25668        let unavailable = shared
25669            .spawn_session(
25670                WorkspaceId::new("primary").unwrap(),
25671                agent("claude"),
25672                SessionMode::Pty,
25673                terminal_size,
25674                None,
25675            )
25676            .await
25677            .unwrap_err();
25678        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25679        assert_eq!(unavailable.message, "backend-operation-failed");
25680
25681        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25682            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25683                agent("claude"),
25684                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25685            ),
25686        ])
25687        .unwrap();
25688        let semantic = shared
25689            .spawn_session(
25690                WorkspaceId::new("primary").unwrap(),
25691                agent("claude"),
25692                SessionMode::Inline,
25693                terminal_size,
25694                None,
25695            )
25696            .await
25697            .unwrap_err();
25698        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25699        assert_eq!(semantic.message, "unsupported-capability");
25700
25701        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25702        assert!(shared
25703            .session_bindings
25704            .lock()
25705            .unwrap_or_else(|poisoned| poisoned.into_inner())
25706            .is_empty());
25707        assert!(shared
25708            .session_records
25709            .lock()
25710            .unwrap_or_else(|poisoned| poisoned.into_inner())
25711            .records
25712            .is_empty());
25713        assert!(shared.handle.snapshot().sessions.is_empty());
25714    }
25715
25716    #[tokio::test]
25717    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25718        let catalog = active_registry().unwrap();
25719        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25720        let workspace_id = WorkspaceId::new("primary").unwrap();
25721        let workspace = WorkspaceConfig::new(
25722            workspace_id.clone(),
25723            std::env::current_dir().unwrap(),
25724        )
25725        .unwrap();
25726        let shared = NodeShared::new(
25727            handle,
25728            "fixture-token".to_owned(),
25729            NodeId::new("node-raw-spawn-record").unwrap(),
25730            vec![workspace],
25731            vec![agent("claude")],
25732        );
25733        let address = SessionAddress {
25734            workspace_id,
25735            session: SessionKey {
25736                instance_id: AgentInstanceId(71),
25737                generation: SessionGeneration::default(),
25738            },
25739        };
25740
25741        let record_id = shared
25742            .bind_spawn_session(
25743                &address,
25744                agent("claude"),
25745                SessionMode::Pty,
25746                ProviderRuntimePolicy::raw_pty(),
25747                SpawnRecordPolicy::Always,
25748                None,
25749            )
25750            .unwrap()
25751            .unwrap();
25752        shared
25753            .handle
25754            .dispatch(shared.prepare_command(ControlCommand::Register {
25755                instance_id: address.session.instance_id,
25756                agent_id: agent("claude"),
25757                transport: TransportKind::Pty,
25758            }))
25759            .unwrap();
25760        runtime.tick().await;
25761
25762        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25763            session.instance_id == address.session.instance_id
25764                && session.generation == address.session.generation
25765        }));
25766        let record = shared.record(&record_id).unwrap();
25767        assert_eq!(record.display_name, "claude #1");
25768        assert_eq!(record.state, ManagedSessionState::Live);
25769        assert_eq!(record.active_session.as_ref(), Some(&address));
25770        assert!(record.provider_session.is_none());
25771        let binding_record_id = shared
25772            .session_bindings
25773            .lock()
25774            .unwrap_or_else(|poisoned| poisoned.into_inner())
25775            .get(&address.session.instance_id)
25776            .and_then(|binding| binding.record_id.clone());
25777        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25778
25779        let renamed = shared
25780            .rename_session_record(&record_id, "raw review".to_owned())
25781            .unwrap();
25782        assert_eq!(renamed.record_id, record_id);
25783        assert_eq!(renamed.display_name, "raw review");
25784        shared.reconcile_managed_record(
25785            &record_id,
25786            &address,
25787            &ControlEvent {
25788                sequence: 1,
25789                command_id: None,
25790                instance_id: address.session.instance_id,
25791                generation: address.session.generation,
25792                event: ControlEventKind::Running { process_id: None },
25793            },
25794            false,
25795            false,
25796        );
25797        let running = shared.record(&record_id).unwrap();
25798        assert_eq!(running.state, ManagedSessionState::Live);
25799        assert_eq!(running.active_session.as_ref(), Some(&address));
25800        assert_eq!(running.display_name, "raw review");
25801
25802        shared.reconcile_managed_record(
25803            &record_id,
25804            &address,
25805            &ControlEvent {
25806                sequence: 2,
25807                command_id: None,
25808                instance_id: address.session.instance_id,
25809                generation: address.session.generation,
25810                event: ControlEventKind::Exited {
25811                    exit_code: Some(0),
25812                    forced: false,
25813                },
25814            },
25815            false,
25816            false,
25817        );
25818        let detached = shared.record(&record_id).unwrap();
25819        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25820        assert!(detached.active_session.is_none());
25821        assert_eq!(detached.display_name, "raw review");
25822        let renamed_detached = shared
25823            .rename_session_record(&record_id, "raw review detached".to_owned())
25824            .unwrap();
25825        assert_eq!(renamed_detached.record_id, record_id);
25826        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25827        assert!(renamed_detached.active_session.is_none());
25828        assert_eq!(renamed_detached.display_name, "raw review detached");
25829        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25830    }
25831
25832    #[test]
25833    fn legacy_raw_spawn_binding_remains_untracked() {
25834        let catalog = active_registry().unwrap();
25835        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25836        let workspace_id = WorkspaceId::new("primary").unwrap();
25837        let workspace = WorkspaceConfig::new(
25838            workspace_id.clone(),
25839            std::env::current_dir().unwrap(),
25840        )
25841        .unwrap();
25842        let shared = NodeShared::new(
25843            handle,
25844            "fixture-token".to_owned(),
25845            NodeId::new("node-legacy-raw-spawn").unwrap(),
25846            vec![workspace],
25847            vec![agent("claude")],
25848        );
25849        let address = SessionAddress {
25850            workspace_id,
25851            session: SessionKey {
25852                instance_id: AgentInstanceId(72),
25853                generation: SessionGeneration::default(),
25854            },
25855        };
25856
25857        let record_id = shared
25858            .bind_spawn_session(
25859                &address,
25860                agent("claude"),
25861                SessionMode::Pty,
25862                ProviderRuntimePolicy::raw_pty(),
25863                SpawnRecordPolicy::ProviderIdentityOnly,
25864                None,
25865            )
25866            .unwrap();
25867
25868        assert!(record_id.is_none());
25869        let binding_record_id = shared
25870            .session_bindings
25871            .lock()
25872            .unwrap_or_else(|poisoned| poisoned.into_inner())
25873            .get(&address.session.instance_id)
25874            .and_then(|binding| binding.record_id.clone());
25875        assert!(binding_record_id.is_none());
25876        assert!(shared.snapshot().session_records.is_empty());
25877    }
25878
25879    #[tokio::test]
25880    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25881        let catalog = active_registry().unwrap();
25882        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25883        let workspace_id = WorkspaceId::new("primary").unwrap();
25884        let workspace = WorkspaceConfig::new(
25885            workspace_id.clone(),
25886            std::env::current_dir().unwrap(),
25887        )
25888        .unwrap();
25889        let mut shared = NodeShared::new(
25890            handle,
25891            "fixture-token".to_owned(),
25892            NodeId::new("node-raw-semantic-gate").unwrap(),
25893            vec![workspace],
25894            vec![agent("claude")],
25895        );
25896        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25897            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25898        ])
25899        .unwrap();
25900        shared
25901            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25902            .unwrap();
25903        let address = SessionAddress {
25904            workspace_id,
25905            session: SessionKey {
25906                instance_id: AgentInstanceId(72),
25907                generation: SessionGeneration::default(),
25908            },
25909        };
25910        shared.bind_session_with_policy(
25911            &address,
25912            ProviderRuntimePolicy::raw_pty(),
25913            None,
25914        );
25915        shared
25916            .handle
25917            .dispatch(shared.prepare_command(ControlCommand::Register {
25918                instance_id: address.session.instance_id,
25919                agent_id: agent("claude"),
25920                transport: TransportKind::Pty,
25921            }))
25922            .unwrap();
25923        runtime.tick().await;
25924        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25925        let revision_before = shared.handle.snapshot().revision;
25926        let history_sequence_before = shared.current_sequence();
25927        let terminal_size = gate4agent_types::TerminalSize {
25928            rows: 24,
25929            columns: 80,
25930        };
25931
25932        for request in [
25933            NodeRequest::Prompt {
25934                session: address.clone(),
25935                text: "prompt".to_owned(),
25936            },
25937            NodeRequest::Paste {
25938                session: address.clone(),
25939                text: "paste".to_owned(),
25940            },
25941            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25942            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25943            // (promptless) resume is a raw PTY relaunch and IS admitted under
25944            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25945            // in `provider_runtime.rs`; it is deliberately not exercised here.
25946            NodeRequest::Resume {
25947                session: address.clone(),
25948                terminal_size,
25949                initial_prompt: Some("resume".to_owned()),
25950            },
25951        ] {
25952            let error = process_request_inner(
25953                &shared,
25954                77,
25955                ClientRole::Operator,
25956                request,
25957            )
25958            .await
25959            .unwrap_err();
25960            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25961        }
25962
25963        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25964        assert_eq!(shared.handle.snapshot().revision, revision_before);
25965        assert_eq!(shared.current_sequence(), history_sequence_before);
25966        let bindings = shared
25967            .session_bindings
25968            .lock()
25969            .unwrap_or_else(|poisoned| poisoned.into_inner());
25970        let binding = bindings.get(&address.session.instance_id).unwrap();
25971        assert_eq!(binding.generation, address.session.generation);
25972        assert!(binding.pending_resume.is_none());
25973        assert!(binding.record_id.is_none());
25974    }
25975
25976    #[tokio::test]
25977    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25978        let catalog = active_registry().unwrap();
25979        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25980        let primary = WorkspaceConfig::new(
25981            WorkspaceId::new("primary").unwrap(),
25982            std::env::current_dir().unwrap(),
25983        )
25984        .unwrap();
25985        let shared = NodeShared::new(
25986            handle,
25987            "fixture-token".to_owned(),
25988            NodeId::new("node-1").unwrap(),
25989            vec![primary],
25990            vec![agent("claude")],
25991        );
25992        shared
25993            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25994            .unwrap();
25995        let before = shared.snapshot();
25996        let error = process_request_inner(
25997            &shared,
25998            77,
25999            ClientRole::Operator,
26000            NodeRequest::RegisterWorkspace {
26001                workspace_id: WorkspaceId::new("foreign").unwrap(),
26002                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26003            },
26004        )
26005        .await
26006        .unwrap_err();
26007        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26008        assert_eq!(error.message, "invalid-request");
26009        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26010    }
26011
26012    #[tokio::test]
26013    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26014        let catalog = active_registry().unwrap();
26015        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26016        let primary_id = WorkspaceId::new("primary").unwrap();
26017        let primary = WorkspaceConfig::new(
26018            primary_id.clone(),
26019            std::env::current_dir().unwrap(),
26020        )
26021        .unwrap();
26022        let shared = NodeShared::new(
26023            handle,
26024            "fixture-token".to_owned(),
26025            NodeId::new("node-1").unwrap(),
26026            vec![primary.clone()],
26027            vec![agent("claude")],
26028        );
26029        let secondary_id = WorkspaceId::new("secondary").unwrap();
26030        let secondary = shared
26031            .register_workspace(
26032                secondary_id.clone(),
26033                std::env::temp_dir().to_string_lossy().into_owned(),
26034            )
26035            .await
26036            .unwrap();
26037        assert_eq!(secondary.workspace_id, secondary_id);
26038        assert!(secondary.sessions.is_empty());
26039        assert_eq!(shared.snapshot().workspaces.len(), 2);
26040
26041        let duplicate_id = shared
26042            .register_workspace(
26043                secondary_id.clone(),
26044                primary.canonical_root().to_owned(),
26045            )
26046            .await
26047            .unwrap_err();
26048        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26049        let duplicate_root = shared
26050            .register_workspace(
26051                WorkspaceId::new("duplicate-root").unwrap(),
26052                windows_path_text(&secondary.canonical_root).to_owned(),
26053            )
26054            .await
26055            .unwrap_err();
26056        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26057
26058        let address = SessionAddress {
26059            workspace_id: secondary_id.clone(),
26060            session: SessionKey {
26061                instance_id: AgentInstanceId(41),
26062                generation: SessionGeneration(1),
26063            },
26064        };
26065        shared.bind_session(
26066            address.session.instance_id,
26067            secondary_id.clone(),
26068            address.session.generation,
26069        );
26070        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26071        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26072        shared.remove_binding(&address);
26073        shared.unregister_workspace(&secondary_id).unwrap();
26074        assert_eq!(shared.snapshot().workspaces.len(), 1);
26075
26076        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26077        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26078        let history = shared
26079            .history
26080            .lock()
26081            .unwrap_or_else(|poisoned| poisoned.into_inner());
26082        assert!(matches!(
26083            &history.events[0],
26084            NodeEventEnvelope {
26085                sequence: 1,
26086                event: NodeEvent::WorkspaceAdded { workspace },
26087            } if workspace.workspace_id == secondary_id
26088        ));
26089        assert!(matches!(
26090            &history.events[1],
26091            NodeEventEnvelope {
26092                sequence: 2,
26093                event: NodeEvent::WorkspaceRemoved { workspace_id },
26094            } if workspace_id == &secondary_id
26095        ));
26096    }
26097
26098    #[tokio::test]
26099    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26100        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26101        std::fs::create_dir_all(&secondary_root).unwrap();
26102        let catalog = active_registry().unwrap();
26103        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26104        let primary_id = WorkspaceId::new("primary").unwrap();
26105        let secondary_id = WorkspaceId::new("secondary").unwrap();
26106        let shared = NodeShared::new(
26107            handle,
26108            "fixture-token".to_owned(),
26109            NodeId::new("node-session-reference-index").unwrap(),
26110            vec![
26111                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26112                    .unwrap(),
26113                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26114            ],
26115            vec![agent("claude"), agent("codex")],
26116        );
26117        let identity = ProviderSessionIdentity {
26118            key: gate4agent_types::ProviderSessionKey::SessionId,
26119            id: "provider-session-42".to_owned(),
26120            transcript_path: None,
26121        };
26122        let index_request = |workspace_id: WorkspaceId,
26123                             provider: AgentId,
26124                             identity: ProviderSessionIdentity,
26125                             display_name: &str| {
26126            NodeRequest::IndexProviderSession {
26127                workspace_id,
26128                provider,
26129                identity,
26130                display_name: display_name.to_owned(),
26131            }
26132        };
26133        let request = index_request(
26134            primary_id.clone(),
26135            agent("claude"),
26136            identity.clone(),
26137            "release shepherd",
26138        );
26139        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26140            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26141        }));
26142        let observer = process_request_inner(
26143            &shared,
26144            77,
26145            ClientRole::Observer,
26146            request.clone(),
26147        )
26148        .await
26149        .unwrap_err();
26150        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26151        let uncontrolled = process_request_inner(
26152            &shared,
26153            77,
26154            ClientRole::Operator,
26155            request.clone(),
26156        )
26157        .await
26158        .unwrap_err();
26159        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26160        shared
26161            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26162            .unwrap();
26163
26164        let indexed = process_request_inner(
26165            &shared,
26166            77,
26167            ClientRole::Operator,
26168            request.clone(),
26169        )
26170        .await
26171        .unwrap();
26172        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26173            panic!("provider session reference index returned another response");
26174        };
26175        assert_eq!(record.state, ManagedSessionState::Dormant);
26176        assert_eq!(record.mode, SessionMode::Pty);
26177        assert_eq!(record.workspace_id, primary_id);
26178        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26179        assert!(record.active_session.is_none());
26180        assert!(record.environment_profile.is_none());
26181        assert!(record.bundle.is_none());
26182        assert!(record.context.is_none());
26183
26184        let repeated = process_request_inner(
26185            &shared,
26186            77,
26187            ClientRole::Operator,
26188            index_request(
26189                primary_id.clone(),
26190                agent("claude"),
26191                identity.clone(),
26192                "ignored replacement",
26193            ),
26194        )
26195        .await
26196        .unwrap();
26197        assert!(matches!(
26198            repeated,
26199            NodeResponse::ProviderSessionIndexed { record: ref existing }
26200                if existing.record_id == record.record_id
26201                    && existing.display_name == "release shepherd"
26202        ));
26203        let conflicting_workspace = process_request_inner(
26204            &shared,
26205            77,
26206            ClientRole::Operator,
26207            index_request(
26208                secondary_id,
26209                agent("claude"),
26210                identity.clone(),
26211                "release shepherd",
26212            ),
26213        )
26214        .await
26215        .unwrap_err();
26216        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26217        let conflicting_provider = process_request_inner(
26218            &shared,
26219            77,
26220            ClientRole::Operator,
26221            index_request(
26222                primary_id.clone(),
26223                agent("codex"),
26224                identity.clone(),
26225                "release shepherd",
26226            ),
26227        )
26228        .await
26229        .unwrap_err();
26230        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26231        let transcript_rejected = process_request_inner(
26232            &shared,
26233            77,
26234            ClientRole::Operator,
26235            index_request(
26236                primary_id,
26237                agent("claude"),
26238                ProviderSessionIdentity {
26239                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26240                    ..identity
26241                },
26242                "release shepherd",
26243            ),
26244        )
26245        .await
26246        .unwrap_err();
26247        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26248        let upserts = shared
26249            .history
26250            .lock()
26251            .unwrap_or_else(|poisoned| poisoned.into_inner())
26252            .events
26253            .iter()
26254            .filter(|event| matches!(
26255                &event.event,
26256                NodeEvent::SessionRecordUpserted { record: published }
26257                    if published.record_id == record.record_id
26258            ))
26259            .count();
26260        assert_eq!(upserts, 1);
26261        std::fs::remove_dir_all(secondary_root).unwrap();
26262    }
26263
26264    #[tokio::test]
26265    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26266        let fixture_root = temporary_workspace_root("standalone-independent");
26267        let target = fixture_root.join("independent");
26268        std::fs::create_dir_all(&fixture_root).unwrap();
26269        let catalog = active_registry().unwrap();
26270        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26271        let selected = WorkspaceConfig::new(
26272            WorkspaceId::new("primary").unwrap(),
26273            std::env::current_dir().unwrap(),
26274        )
26275        .unwrap();
26276        let selected_root = selected.canonical_root().to_owned();
26277        let shared = NodeShared::new(
26278            handle,
26279            "fixture-token".to_owned(),
26280            NodeId::new("node-standalone").unwrap(),
26281            vec![selected],
26282            vec![agent("claude")],
26283        );
26284        let workspace_id = WorkspaceId::new("independent").unwrap();
26285        let request = NodeRequest::CreateStandaloneWorkspace {
26286            workspace_id: workspace_id.clone(),
26287            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26288            initial_branch: Some("main".to_owned()),
26289        };
26290        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26291            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26292        }));
26293        assert!(!request_uses_unnegotiated_capability(
26294            &request,
26295            &baseline_capabilities().unwrap(),
26296        ));
26297        assert!(request_uses_unnegotiated_capability(&request, &[]));
26298
26299        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26300            .await
26301            .unwrap_err();
26302        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26303        assert!(!target.exists());
26304        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26305            .await
26306            .unwrap_err();
26307        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26308        assert!(!target.exists());
26309        shared
26310            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26311            .unwrap();
26312
26313        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26314            .await
26315            .unwrap();
26316        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26317            panic!("standalone workspace creation returned another response");
26318        };
26319        assert_eq!(workspace.workspace_id, workspace_id);
26320        assert!(target.join(".git").is_dir());
26321        let branch = run_git_bounded(
26322            target.to_str().unwrap(),
26323            &["branch", "--show-current"],
26324            GIT_OUTPUT_MAX_BYTES,
26325        )
26326        .await
26327        .unwrap();
26328        assert!(branch.success);
26329        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26330        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26331        assert_eq!(worktrees.len(), 1);
26332        let listed_root = WorkspaceConfig::new(
26333            WorkspaceId::new("listed-independent").unwrap(),
26334            &worktrees[0].path,
26335        )
26336        .unwrap();
26337        assert!(platform::roots_equal(
26338            listed_root.canonical_root(),
26339            windows_path_text(&workspace.canonical_root),
26340        ));
26341        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26342        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26343            snapshot.workspace_id == workspace_id
26344                && snapshot.canonical_root == workspace.canonical_root
26345        }));
26346        assert!(shared
26347            .history
26348            .lock()
26349            .unwrap_or_else(|poisoned| poisoned.into_inner())
26350            .events
26351            .iter()
26352            .any(|event| matches!(
26353                &event.event,
26354                NodeEvent::WorkspaceAdded { workspace }
26355                    if workspace.workspace_id == workspace_id
26356            )));
26357        std::fs::remove_dir_all(fixture_root).unwrap();
26358    }
26359
26360    #[tokio::test]
26361    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26362        let fixture_root = temporary_workspace_root("standalone-compensation");
26363        let created_target = fixture_root.join("created-target");
26364        let existing_target = fixture_root.join("existing-target");
26365        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26366        let invalid_state_path = invalid_state_parent.join("state.json");
26367        std::fs::create_dir_all(&existing_target).unwrap();
26368        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26369        let catalog = active_registry().unwrap();
26370        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26371        let primary = WorkspaceConfig::new(
26372            WorkspaceId::new("primary").unwrap(),
26373            std::env::current_dir().unwrap(),
26374        )
26375        .unwrap();
26376        let mut shared = NodeShared::new(
26377            handle,
26378            "fixture-token".to_owned(),
26379            NodeId::new("node-standalone-compensation").unwrap(),
26380            vec![primary],
26381            vec![agent("claude")],
26382        );
26383        shared.state_path = Some(invalid_state_path);
26384
26385        let created_recovery = shared
26386            .create_standalone_workspace(
26387                WorkspaceId::new("compensated").unwrap(),
26388                created_target.to_string_lossy().into_owned(),
26389                Some("main".to_owned()),
26390            )
26391            .await
26392            .unwrap_err();
26393        assert_eq!(
26394            created_recovery.code,
26395            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26396        );
26397        assert!(created_target.join(".git").is_dir());
26398        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26399            workspace.workspace_id.as_str() != "compensated"
26400        }));
26401
26402        let existing_recovery = shared
26403            .create_standalone_workspace(
26404                WorkspaceId::new("restored").unwrap(),
26405                existing_target.to_string_lossy().into_owned(),
26406                None,
26407            )
26408            .await
26409            .unwrap_err();
26410        assert_eq!(
26411            existing_recovery.code,
26412            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26413        );
26414        assert!(existing_target.is_dir());
26415        assert!(existing_target.join(".git").is_dir());
26416        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26417            workspace.workspace_id.as_str() != "restored"
26418        }));
26419
26420        let registered_empty = fixture_root.join("registered-empty");
26421        std::fs::create_dir(&registered_empty).unwrap();
26422        let catalog = active_registry().unwrap();
26423        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26424        let registered = WorkspaceConfig::new(
26425            WorkspaceId::new("registered-empty").unwrap(),
26426            &registered_empty,
26427        )
26428        .unwrap();
26429        let duplicate_shared = NodeShared::new(
26430            handle,
26431            "fixture-token".to_owned(),
26432            NodeId::new("node-standalone-duplicate-root").unwrap(),
26433            vec![registered],
26434            vec![agent("claude")],
26435        );
26436        let duplicate = duplicate_shared
26437            .create_standalone_workspace(
26438                WorkspaceId::new("duplicate-root").unwrap(),
26439                registered_empty.to_string_lossy().into_owned(),
26440                None,
26441            )
26442            .await
26443            .unwrap_err();
26444        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26445        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26446        std::fs::remove_dir_all(fixture_root).unwrap();
26447    }
26448
26449    #[test]
26450    fn managed_record_id_collision_preserves_the_existing_record() {
26451        let catalog = active_registry().unwrap();
26452        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26453        let workspace = WorkspaceConfig::new(
26454            WorkspaceId::new("primary").unwrap(),
26455            std::env::current_dir().unwrap(),
26456        )
26457        .unwrap();
26458        let shared = NodeShared::new(
26459            handle,
26460            "fixture-token".to_owned(),
26461            NodeId::new("node-1").unwrap(),
26462            vec![workspace.clone()],
26463            vec![agent("claude")],
26464        );
26465        let record_id = SessionRecordId::new("sr-collision").unwrap();
26466        let original = ManagedSessionRecord {
26467            record_id: record_id.clone(),
26468            display_name: "original".to_owned(),
26469            provider: agent("claude"),
26470            mode: SessionMode::Pty,
26471            state: ManagedSessionState::Dormant,
26472            workspace_id: workspace.workspace_id,
26473            canonical_root: opaque_windows_path(workspace.canonical_root),
26474            provider_session: None,
26475            active_session: None,
26476            environment_profile: None,
26477            bundle: None,
26478            context_id: None,
26479            context: None,
26480            exported_context: None,
26481            task_binding: None,
26482            created_at_unix_ms: 1,
26483            updated_at_unix_ms: 1,
26484            last_error: None,
26485        };
26486        shared.insert_record(original.clone()).unwrap();
26487        let mut collision = original.clone();
26488        collision.display_name = "replacement".to_owned();
26489        let error = shared.insert_record(collision).unwrap_err();
26490        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26491        assert_eq!(shared.record(&record_id).unwrap(), original);
26492    }
26493
26494    #[test]
26495    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26496        let catalog = active_registry().unwrap();
26497        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26498        let workspace = WorkspaceConfig::new(
26499            WorkspaceId::new("primary").unwrap(),
26500            std::env::current_dir().unwrap(),
26501        )
26502        .unwrap();
26503        let shared = NodeShared::new(
26504            handle,
26505            "fixture-token".to_owned(),
26506            NodeId::new("node-1").unwrap(),
26507            vec![workspace.clone()],
26508            vec![agent("claude")],
26509        );
26510        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26511        shared
26512            .insert_record(ManagedSessionRecord {
26513                record_id: record_id.clone(),
26514                display_name: "runtime error".to_owned(),
26515                provider: agent("claude"),
26516                mode: SessionMode::Pty,
26517                state: ManagedSessionState::IdentityPending,
26518                workspace_id: workspace.workspace_id,
26519                canonical_root: opaque_windows_path(workspace.canonical_root),
26520                provider_session: None,
26521                active_session: None,
26522                environment_profile: None,
26523                bundle: None,
26524                context_id: None,
26525                context: None,
26526                exported_context: None,
26527                task_binding: None,
26528                created_at_unix_ms: 1,
26529                updated_at_unix_ms: 1,
26530                last_error: None,
26531            })
26532            .unwrap();
26533
26534        shared
26535            .mark_record_error(
26536                &record_id,
26537                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26538            )
26539            .unwrap();
26540        let record = shared.record(&record_id).unwrap();
26541        assert_eq!(record.state, ManagedSessionState::Unavailable);
26542        assert_eq!(
26543            record.last_error.as_deref(),
26544            Some("provider-runtime-failed"),
26545        );
26546        let snapshot = shared.snapshot();
26547        assert_eq!(snapshot.session_records, vec![record]);
26548    }
26549
26550    /// Regression coverage for the "operator sees `deadline` instead of the
26551    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26552    /// able to learn a command's asynchronous kernel rejection (here,
26553    /// specifically classified as `UnsupportedTransport`) exactly once, and
26554    /// must not still find it after that -- proving the fast path this
26555    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26556    /// loop) drains the reason instead of leaving it to be rediscovered (or
26557    /// missed) on a later poll.
26558    #[test]
26559    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26560        let catalog = active_registry().unwrap();
26561        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26562        let workspace = WorkspaceConfig::new(
26563            WorkspaceId::new("primary").unwrap(),
26564            std::env::current_dir().unwrap(),
26565        )
26566        .unwrap();
26567        let shared = NodeShared::new(
26568            handle,
26569            "fixture-token".to_owned(),
26570            NodeId::new("node-1").unwrap(),
26571            vec![workspace],
26572            vec![agent("claude")],
26573        );
26574
26575        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26576
26577        shared.record_rejected_command(
26578            CommandId(1),
26579            "agent 'claude' does not support transport Acp".to_owned(),
26580            true,
26581        );
26582        // An unrelated command_id must not see another command's reason.
26583        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26584
26585        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26586        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26587        assert!(reason.unsupported_transport);
26588
26589        // Consumed: a second read for the same command_id finds nothing.
26590        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26591
26592        // A rejection unrelated to transport support keeps its own
26593        // classification distinct, so the reader can choose the generic
26594        // `BackendOperationFailed` code instead of misreporting
26595        // `UnsupportedTransport`.
26596        shared.record_rejected_command(
26597            CommandId(3),
26598            "backend busy".to_owned(),
26599            false,
26600        );
26601        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26602        assert!(!unclassified.unsupported_transport);
26603    }
26604
26605    #[test]
26606    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26607        let catalog = active_registry().unwrap();
26608        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26609        let workspace = WorkspaceConfig::new(
26610            WorkspaceId::new("primary").unwrap(),
26611            std::env::current_dir().unwrap(),
26612        )
26613        .unwrap();
26614        let shared = NodeShared::new(
26615            handle,
26616            "fixture-token".to_owned(),
26617            NodeId::new("node-identity-policy").unwrap(),
26618            vec![workspace.clone()],
26619            vec![agent("claude")],
26620        );
26621        let address = SessionAddress {
26622            workspace_id: workspace.workspace_id.clone(),
26623            session: SessionKey {
26624                instance_id: AgentInstanceId(90),
26625                generation: SessionGeneration(1),
26626            },
26627        };
26628        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26629        shared
26630            .insert_record(ManagedSessionRecord {
26631                record_id: record_id.clone(),
26632                display_name: "policy denied".to_owned(),
26633                provider: agent("claude"),
26634                mode: SessionMode::Pty,
26635                state: ManagedSessionState::IdentityPending,
26636                workspace_id: workspace.workspace_id,
26637                canonical_root: opaque_windows_path(workspace.canonical_root),
26638                provider_session: None,
26639                active_session: Some(address.clone()),
26640                environment_profile: None,
26641                bundle: None,
26642                context_id: None,
26643                context: None,
26644                exported_context: None,
26645                task_binding: None,
26646                created_at_unix_ms: 1,
26647                updated_at_unix_ms: 1,
26648                last_error: None,
26649            })
26650            .unwrap();
26651        let event = ControlEvent {
26652            sequence: 1,
26653            command_id: None,
26654            instance_id: address.session.instance_id,
26655            generation: address.session.generation,
26656            event: ControlEventKind::ProviderEvent {
26657                sequence: 1,
26658                source: gate4agent_types::ProviderSource {
26659                    family: AdapterFamily::PtySemantic,
26660                    binding: AdapterBinding::new(
26661                        gate4agent_types::AdapterId::new("claude").unwrap(),
26662                        "fixture/v1",
26663                        gate4agent_types::AdapterVerification::SyntheticFixture,
26664                    )
26665                    .unwrap(),
26666                },
26667                source_sequence: 1,
26668                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26669                    identity: gate4agent_types::ProviderSessionIdentity {
26670                        key: gate4agent_types::ProviderSessionKey::SessionId,
26671                        id: "provider-session".to_owned(),
26672                        transcript_path: None,
26673                    },
26674                },
26675            },
26676        };
26677
26678        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26679
26680        let record = shared.record(&record_id).unwrap();
26681        assert!(record.provider_session.is_none());
26682        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26683        assert_eq!(record.active_session.as_ref(), Some(&address));
26684    }
26685
26686    #[test]
26687    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26688        let secondary_root = temporary_workspace_root("identity-scope");
26689        std::fs::create_dir_all(&secondary_root).unwrap();
26690        let catalog = active_registry().unwrap();
26691        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26692        let primary = WorkspaceConfig::new(
26693            WorkspaceId::new("primary").unwrap(),
26694            std::env::current_dir().unwrap(),
26695        )
26696        .unwrap();
26697        let secondary = WorkspaceConfig::new(
26698            WorkspaceId::new("secondary").unwrap(),
26699            &secondary_root,
26700        )
26701        .unwrap();
26702        let shared = NodeShared::new(
26703            handle,
26704            "fixture-token".to_owned(),
26705            NodeId::new("node-1").unwrap(),
26706            vec![primary.clone(), secondary.clone()],
26707            vec![agent("claude")],
26708        );
26709        let identity = gate4agent_types::ProviderSessionIdentity {
26710            key: gate4agent_types::ProviderSessionKey::SessionId,
26711            id: "provider-session".to_owned(),
26712            transcript_path: None,
26713        };
26714        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26715        shared
26716            .insert_record(ManagedSessionRecord {
26717                record_id: dormant_id.clone(),
26718                display_name: "dormant".to_owned(),
26719                provider: agent("claude"),
26720                mode: SessionMode::Pty,
26721                state: ManagedSessionState::Dormant,
26722                workspace_id: primary.workspace_id,
26723                canonical_root: opaque_windows_path(primary.canonical_root),
26724                provider_session: Some(identity.clone()),
26725                active_session: None,
26726                environment_profile: None,
26727                bundle: None,
26728                context_id: None,
26729                context: None,
26730                exported_context: None,
26731                task_binding: None,
26732                created_at_unix_ms: 1,
26733                updated_at_unix_ms: 1,
26734                last_error: None,
26735            })
26736            .unwrap();
26737        let address = SessionAddress {
26738            workspace_id: secondary.workspace_id.clone(),
26739            session: SessionKey {
26740                instance_id: AgentInstanceId(91),
26741                generation: SessionGeneration(1),
26742            },
26743        };
26744        let current_id = SessionRecordId::new("sr-current").unwrap();
26745        shared
26746            .insert_record(ManagedSessionRecord {
26747                record_id: current_id.clone(),
26748                display_name: "current".to_owned(),
26749                provider: agent("claude"),
26750                mode: SessionMode::Pty,
26751                state: ManagedSessionState::IdentityPending,
26752                workspace_id: secondary.workspace_id,
26753                canonical_root: opaque_windows_path(secondary.canonical_root),
26754                provider_session: None,
26755                active_session: Some(address.clone()),
26756                environment_profile: None,
26757                bundle: None,
26758                context_id: None,
26759                context: None,
26760                exported_context: None,
26761                task_binding: None,
26762                created_at_unix_ms: 2,
26763                updated_at_unix_ms: 2,
26764                last_error: None,
26765            })
26766            .unwrap();
26767        shared.bind_managed_session(
26768            &address,
26769            current_id.clone(),
26770            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26771            None,
26772        );
26773        let event = ControlEvent {
26774            sequence: 1,
26775            command_id: None,
26776            instance_id: address.session.instance_id,
26777            generation: address.session.generation,
26778            event: ControlEventKind::ProviderEvent {
26779                sequence: 1,
26780                source: gate4agent_types::ProviderSource {
26781                    family: gate4agent_types::AdapterFamily::PtySemantic,
26782                    binding: gate4agent_types::AdapterBinding::new(
26783                        gate4agent_types::AdapterId::new("claude").unwrap(),
26784                        "fixture/v1",
26785                        gate4agent_types::AdapterVerification::SyntheticFixture,
26786                    )
26787                    .unwrap(),
26788                },
26789                source_sequence: 1,
26790                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26791                    identity,
26792                },
26793            },
26794        };
26795        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26796
26797        let binding = shared
26798            .session_bindings
26799            .lock()
26800            .unwrap_or_else(|poisoned| poisoned.into_inner())
26801            .get(&address.session.instance_id)
26802            .cloned()
26803            .unwrap();
26804        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26805        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26806        let current = shared.record(&current_id).unwrap();
26807        assert_eq!(current.state, ManagedSessionState::Unavailable);
26808        assert_eq!(current.active_session.as_ref(), Some(&address));
26809        assert_eq!(
26810            current.last_error.as_deref(),
26811            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26812        );
26813        std::fs::remove_dir_all(secondary_root).unwrap();
26814    }
26815
26816    #[tokio::test]
26817    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26818        let root = temporary_workspace_root("semantic-provider-session");
26819        std::fs::create_dir_all(&root).unwrap();
26820        let state_path = root.join("state-v1.json");
26821        let catalog = active_registry().unwrap();
26822        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26823        let workspace = WorkspaceConfig::new(
26824            WorkspaceId::new("primary").unwrap(),
26825            &root,
26826        )
26827        .unwrap();
26828        let node_id = NodeId::new("node-semantic-identity").unwrap();
26829        let shared = NodeShared::new_with_incarnation(
26830            handle,
26831            "fixture-token".to_owned(),
26832            node_id.clone(),
26833            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26834            vec![workspace.clone()],
26835            vec![agent("claude")],
26836            ProviderRuntimeStatuses::default(),
26837            None,
26838            Vec::new(),
26839            Vec::new(),
26840            SpawnProfileRegistry::default(),
26841            None,
26842            None,
26843            None,
26844            Some(state_path.clone()),
26845            Vec::new(),
26846            Vec::new(),
26847            Vec::new(),
26848            Vec::new(),
26849            None,
26850            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26851        );
26852        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26853            key: gate4agent_types::ProviderSessionKey::SessionId,
26854            id: "provider-session".to_owned(),
26855            transcript_path: None,
26856        };
26857        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26858        shared
26859            .insert_record(ManagedSessionRecord {
26860                record_id: durable_id.clone(),
26861                display_name: "durable".to_owned(),
26862                provider: agent("claude"),
26863                mode: SessionMode::Pty,
26864                state: ManagedSessionState::Dormant,
26865                workspace_id: workspace.workspace_id.clone(),
26866                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26867                provider_session: Some(persisted_identity.clone()),
26868                active_session: None,
26869                environment_profile: None,
26870                bundle: None,
26871                context_id: None,
26872                context: None,
26873                exported_context: None,
26874                task_binding: None,
26875                created_at_unix_ms: 1,
26876                updated_at_unix_ms: 1,
26877                last_error: None,
26878            })
26879            .unwrap();
26880        let address = SessionAddress {
26881            workspace_id: workspace.workspace_id.clone(),
26882            session: SessionKey {
26883                instance_id: AgentInstanceId(92),
26884                generation: SessionGeneration(2),
26885            },
26886        };
26887        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26888        shared
26889            .insert_record(ManagedSessionRecord {
26890                record_id: pending_id.clone(),
26891                display_name: "pending".to_owned(),
26892                provider: agent("claude"),
26893                mode: SessionMode::Pty,
26894                state: ManagedSessionState::IdentityPending,
26895                workspace_id: workspace.workspace_id.clone(),
26896                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26897                provider_session: None,
26898                active_session: Some(address.clone()),
26899                environment_profile: None,
26900                bundle: None,
26901                context_id: None,
26902                context: None,
26903                exported_context: None,
26904                task_binding: None,
26905                created_at_unix_ms: 2,
26906                updated_at_unix_ms: 2,
26907                last_error: None,
26908            })
26909            .unwrap();
26910        shared.bind_managed_session(
26911            &address,
26912            pending_id.clone(),
26913            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26914            None,
26915        );
26916        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26917            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26918            ..persisted_identity
26919        };
26920        let event = ControlEvent {
26921            sequence: 1,
26922            command_id: None,
26923            instance_id: address.session.instance_id,
26924            generation: address.session.generation,
26925            event: ControlEventKind::ProviderEvent {
26926                sequence: 1,
26927                source: gate4agent_types::ProviderSource {
26928                    family: gate4agent_types::AdapterFamily::PtySemantic,
26929                    binding: gate4agent_types::AdapterBinding::new(
26930                        gate4agent_types::AdapterId::new("claude").unwrap(),
26931                        "fixture/v1",
26932                        gate4agent_types::AdapterVerification::SyntheticFixture,
26933                    )
26934                    .unwrap(),
26935                },
26936                source_sequence: 1,
26937                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26938                    identity: observed_identity.clone(),
26939                },
26940            },
26941        };
26942        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26943
26944        let snapshot = shared.snapshot();
26945        assert_eq!(snapshot.session_records.len(), 1);
26946        let live = &snapshot.session_records[0];
26947        assert_eq!(live.record_id, durable_id);
26948        assert_eq!(live.state, ManagedSessionState::Live);
26949        assert_eq!(live.active_session.as_ref(), Some(&address));
26950        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26951        let binding = shared
26952            .session_bindings
26953            .lock()
26954            .unwrap_or_else(|poisoned| poisoned.into_inner())
26955            .get(&address.session.instance_id)
26956            .cloned()
26957            .unwrap();
26958        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26959
26960        let mut duplicate = live.clone();
26961        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26962        duplicate.display_name = "duplicate".to_owned();
26963        duplicate.state = ManagedSessionState::Dormant;
26964        duplicate.active_session = None;
26965        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26966        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26967        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26968        assert_eq!(shared.snapshot().session_records.len(), 1);
26969
26970        let terminal_size = gate4agent_types::TerminalSize {
26971            rows: 24,
26972            columns: 80,
26973        };
26974        let (first_busy, second_busy) = tokio::join!(
26975            shared.resume_session_record(&durable_id, terminal_size, None),
26976            shared.resume_session_record(&durable_id, terminal_size, None),
26977        );
26978        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26979            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26980            assert_eq!(busy.message, "session-record-busy");
26981        }
26982
26983        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26984        assert_eq!(loaded.records.len(), 1);
26985        assert_eq!(loaded.records[0].record_id, durable_id);
26986        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26987        assert_eq!(
26988            loaded.records[0]
26989                .provider_session
26990                .as_ref()
26991                .unwrap()
26992                .transcript_path,
26993            None,
26994        );
26995        let catalog = active_registry().unwrap();
26996        let (reloaded_handle, _reloaded_runtime) =
26997            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26998        let reloaded = NodeShared::new_with_incarnation(
26999            reloaded_handle,
27000            "fixture-token".to_owned(),
27001            node_id,
27002            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27003            vec![workspace],
27004            vec![agent("claude")],
27005            ProviderRuntimeStatuses::default(),
27006            None,
27007            Vec::new(),
27008            Vec::new(),
27009            SpawnProfileRegistry::default(),
27010            None,
27011            None,
27012            None,
27013            None,
27014            loaded.records,
27015            loaded.managed_worktrees,
27016            loaded.managed_worktree_tombstones,
27017            loaded.materializations,
27018            loaded.warning,
27019            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27020        );
27021        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27022        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27023        std::fs::remove_dir_all(root).unwrap();
27024    }
27025
27026    #[tokio::test]
27027    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27028        let secondary_root = temporary_workspace_root("workspace-restore");
27029        let other_root = temporary_workspace_root("workspace-conflict");
27030        std::fs::create_dir_all(&secondary_root).unwrap();
27031        std::fs::create_dir_all(&other_root).unwrap();
27032        let catalog = active_registry().unwrap();
27033        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27034        let primary = WorkspaceConfig::new(
27035            WorkspaceId::new("primary").unwrap(),
27036            std::env::current_dir().unwrap(),
27037        )
27038        .unwrap();
27039        let secondary = WorkspaceConfig::new(
27040            WorkspaceId::new("secondary").unwrap(),
27041            &secondary_root,
27042        )
27043        .unwrap();
27044        let shared = NodeShared::new(
27045            handle,
27046            "fixture-token".to_owned(),
27047            NodeId::new("node-1").unwrap(),
27048            vec![primary, secondary.clone()],
27049            vec![agent("claude")],
27050        );
27051        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27052        shared
27053            .insert_record(ManagedSessionRecord {
27054                record_id: record_id.clone(),
27055                display_name: "workspace record".to_owned(),
27056                provider: agent("claude"),
27057                mode: SessionMode::Pty,
27058                state: ManagedSessionState::Dormant,
27059                workspace_id: secondary.workspace_id.clone(),
27060                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27061                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27062                    key: gate4agent_types::ProviderSessionKey::SessionId,
27063                    id: "provider-session".to_owned(),
27064                    transcript_path: None,
27065                }),
27066                active_session: None,
27067                environment_profile: None,
27068                bundle: None,
27069                context_id: None,
27070                context: None,
27071                exported_context: None,
27072                task_binding: None,
27073                created_at_unix_ms: 1,
27074                updated_at_unix_ms: 1,
27075                last_error: None,
27076            })
27077            .unwrap();
27078        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27079        let unavailable = shared.record(&record_id).unwrap();
27080        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27081        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27082
27083        let conflict = shared
27084            .register_workspace(
27085                secondary.workspace_id.clone(),
27086                other_root.to_string_lossy().into_owned(),
27087            )
27088            .await
27089            .unwrap_err();
27090        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27091        shared
27092            .register_workspace(
27093                secondary.workspace_id,
27094                secondary_root.to_string_lossy().into_owned(),
27095            )
27096            .await
27097            .unwrap();
27098        let restored = shared.record(&record_id).unwrap();
27099        assert_eq!(restored.state, ManagedSessionState::Dormant);
27100        assert!(restored.last_error.is_none());
27101        std::fs::remove_dir_all(secondary_root).unwrap();
27102        std::fs::remove_dir_all(other_root).unwrap();
27103    }
27104
27105    #[test]
27106    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27107        let error = validate_workspace_request_root(
27108            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27109        )
27110        .unwrap_err();
27111        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27112        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27113        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27114    }
27115
27116    #[test]
27117    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27118        let catalog = active_registry().unwrap();
27119        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27120        let workspace = WorkspaceConfig::new(
27121            WorkspaceId::new("primary").unwrap(),
27122            std::env::current_dir().unwrap(),
27123        )
27124        .unwrap();
27125        let shared = NodeShared::new(
27126            handle,
27127            "fixture-token".to_owned(),
27128            NodeId::new("node-1").unwrap(),
27129            vec![workspace],
27130            vec![agent("claude")],
27131        );
27132        let publishers = 4_u64;
27133        let events_per_publisher = 256_u64;
27134        let start = std::sync::Barrier::new(publishers as usize + 1);
27135        let finished = AtomicU64::new(0);
27136
27137        std::thread::scope(|scope| {
27138            for _ in 0..publishers {
27139                scope.spawn(|| {
27140                    start.wait();
27141                    for _ in 0..events_per_publisher {
27142                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27143                    }
27144                    finished.fetch_add(1, Ordering::Release);
27145                });
27146            }
27147            start.wait();
27148            while finished.load(Ordering::Acquire) != publishers {
27149                assert_resync_history_is_linearized(shared.resync(0));
27150                std::thread::yield_now();
27151            }
27152        });
27153        let response = shared.resync(0);
27154        assert_resync_history_is_linearized(response.clone());
27155        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27156            unreachable!("resync helper returned another response");
27157        };
27158        assert_eq!(event_sequence, publishers * events_per_publisher);
27159        assert_eq!(events.len() as u64, event_sequence);
27160    }
27161
27162    fn assert_resync_history_is_linearized(response: NodeResponse) {
27163        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27164            unreachable!("resync helper returned another response");
27165        };
27166        assert!(events.windows(2).all(|pair| {
27167            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27168        }));
27169        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27170    }
27171
27172    #[test]
27173    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27174        assert_eq!(
27175            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27176            r"C:\repo\worktree",
27177        );
27178        assert_eq!(
27179            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27180            r"\\server\share\repo",
27181        );
27182        assert!(validate_workspace_root(
27183            &WorkspaceId::new("network").unwrap(),
27184            r"\\server\share\repo",
27185        )
27186        .is_err());
27187        let workspace = WorkspaceConfig::new(
27188            WorkspaceId::new("primary").unwrap(),
27189            std::env::current_dir().unwrap(),
27190        )
27191        .unwrap();
27192        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27193    }
27194
27195    #[test]
27196    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27197        let catalog = active_registry().unwrap();
27198        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27199        let workspace_id = WorkspaceId::new("primary").unwrap();
27200        let workspace = WorkspaceConfig::new(
27201            workspace_id.clone(),
27202            std::env::current_dir().unwrap(),
27203        )
27204        .unwrap();
27205        let shared = NodeShared::new(
27206            handle,
27207            "fixture-token".to_owned(),
27208            NodeId::new("node-1").unwrap(),
27209            vec![workspace],
27210            vec![agent("claude")],
27211        );
27212        let instance_id = AgentInstanceId(17);
27213        let original = SessionAddress {
27214            workspace_id,
27215            session: SessionKey {
27216                instance_id,
27217                generation: SessionGeneration(3),
27218            },
27219        };
27220        shared.bind_session(
27221            instance_id,
27222            original.workspace_id.clone(),
27223            original.session.generation,
27224        );
27225        shared
27226            .arm_resume(
27227                &original,
27228                CommandId(41),
27229                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27230            )
27231            .unwrap();
27232
27233        shared.publish_control(ControlEvent {
27234            sequence: 1,
27235            command_id: None,
27236            instance_id,
27237            generation: SessionGeneration(4),
27238            event: ControlEventKind::Running { process_id: None },
27239        });
27240        assert_eq!(
27241            shared.address_for(instance_id, SessionGeneration(3)),
27242            Some(original.clone()),
27243        );
27244        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27245
27246        shared.publish_control(ControlEvent {
27247            sequence: 2,
27248            command_id: Some(CommandId(42)),
27249            instance_id,
27250            generation: SessionGeneration(3),
27251            event: ControlEventKind::CommandRejected {
27252                message: "unrelated command".to_owned(),
27253            },
27254        });
27255
27256        shared.publish_control(ControlEvent {
27257            sequence: 3,
27258            command_id: None,
27259            instance_id,
27260            generation: SessionGeneration(4),
27261            event: ControlEventKind::ResumeAuthorized {
27262                session: gate4agent_types::ResumeSessionSummary {
27263                    key: gate4agent_types::ProviderSessionKey::SessionId,
27264                    id: "fixture-session".to_owned(),
27265                },
27266            },
27267        });
27268        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27269        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27270        assert_eq!(rebound.workspace_id, original.workspace_id);
27271        let history = shared
27272            .history
27273            .lock()
27274            .unwrap_or_else(|poisoned| poisoned.into_inner());
27275        assert!(history.events.iter().any(|envelope| matches!(
27276            &envelope.event,
27277            NodeEvent::Control { address, event }
27278                if address == &rebound
27279                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27280        )));
27281    }
27282
27283    #[test]
27284    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27285        for agent in ["claude", "codex", "kimi"] {
27286            assert_eq!(
27287                prompt_framing(&AgentId::new(agent).unwrap()),
27288                PromptFraming::BracketedPaste
27289            );
27290        }
27291    }
27292
27293    fn generous_walk_deadline() -> Instant {
27294        Instant::now() + Duration::from_secs(30)
27295    }
27296
27297    #[test]
27298    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27299        let root = temporary_workspace_root("tree");
27300        std::fs::create_dir_all(root.join("src")).unwrap();
27301        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27302        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27303        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27304        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27305        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27306        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27307        // real repository's own build layout) that carries the Cache
27308        // Directory Tagging Specification marker cargo writes into every
27309        // `--target-dir` it creates: must be skipped even though its name
27310        // does not exactly match `target`.
27311        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27312        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27313        // A directory that merely *starts with* `target` but carries no
27314        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27315        // on the name alone, so this stays in the response.
27316        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27317        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27318
27319        let (entries, truncated, _budget) =
27320            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27321        assert!(!truncated);
27322        assert!(entries.iter().any(|entry| {
27323            entry.relative_path.as_utf8() == Some("src")
27324                && entry.kind == WorkspaceEntryKind::Directory
27325        }));
27326        assert!(entries.iter().any(|entry| {
27327            entry.relative_path.as_utf8() == Some("src/lib.rs")
27328                && entry.kind == WorkspaceEntryKind::File
27329        }));
27330        assert!(entries.iter().any(|entry| {
27331            entry.relative_path.as_utf8() == Some("target-notes")
27332                && entry.kind == WorkspaceEntryKind::Directory
27333        }));
27334        assert!(entries.iter().any(|entry| {
27335            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27336                && entry.kind == WorkspaceEntryKind::File
27337        }));
27338        assert!(entries.iter().all(|entry| {
27339            let path = entry.relative_path.as_utf8().unwrap();
27340            !path.starts_with(".git")
27341                && !(path == "target" || path.starts_with("target/"))
27342                && !(path == "target-a2" || path.starts_with("target-a2/"))
27343                && !path.starts_with("node_modules")
27344        }));
27345
27346        std::fs::remove_dir_all(&root).unwrap();
27347    }
27348
27349    #[test]
27350    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27351        let root = temporary_workspace_root("tree-entry-cap");
27352        std::fs::create_dir_all(&root).unwrap();
27353        for index in 0..20 {
27354            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27355        }
27356
27357        let (entries, truncated, budget) =
27358            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27359        assert!(truncated);
27360        assert!(budget.entry_cap_exceeded);
27361        assert!(!budget.time_budget_exceeded);
27362        assert!(entries.len() <= 5);
27363
27364        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27365            .expect("an entry-cap-truncated walk must report a truncation marker");
27366        assert!(inspection_truncation.walk_entry_cap_exceeded);
27367        assert!(!inspection_truncation.walk_time_budget_exceeded);
27368        assert!(!inspection_truncation.git_time_budget_exceeded);
27369        assert!(inspection_truncation.entries_visited >= 5);
27370
27371        std::fs::remove_dir_all(&root).unwrap();
27372    }
27373
27374    #[test]
27375    fn workspace_walk_within_budget_reports_no_truncation() {
27376        let root = temporary_workspace_root("tree-no-truncation");
27377        std::fs::create_dir_all(&root).unwrap();
27378        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27379
27380        let (_entries, truncated, budget) =
27381            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27382        assert!(!truncated);
27383        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27384
27385        std::fs::remove_dir_all(&root).unwrap();
27386    }
27387
27388    #[test]
27389    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27390        let root = temporary_workspace_root("tree-prefix-siblings");
27391        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27392        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27393        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27394        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27395
27396        let (entries, truncated, _budget) =
27397            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27398        assert!(!truncated);
27399        let paths = entries
27400            .iter()
27401            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27402            .collect::<Vec<_>>();
27403        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27404        assert_eq!(
27405            paths,
27406            vec![
27407                "gate4agent-c2".to_owned(),
27408                "gate4agent-c2-client".to_owned(),
27409                "gate4agent-c2-client/lib.rs".to_owned(),
27410                "gate4agent-c2/src".to_owned(),
27411                "gate4agent-c2/src/lib.rs".to_owned(),
27412            ],
27413        );
27414
27415        std::fs::remove_dir_all(&root).unwrap();
27416    }
27417
27418    #[test]
27419    fn git_parsers_cap_status_and_commit_results() {
27420        let mut status_output = Vec::new();
27421        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27422            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27423        }
27424        let mut snapshot = git_snapshot_for_parser();
27425        parse_git_status(&status_output, &mut snapshot);
27426        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27427        assert!(snapshot.truncated);
27428        assert_eq!(snapshot.status[0].index_status, " ");
27429        assert_eq!(snapshot.status[0].worktree_status, "M");
27430
27431        snapshot.truncated = false;
27432        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27433            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27434            .collect::<String>();
27435        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27436        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27437        assert!(snapshot.truncated);
27438    }
27439
27440    #[test]
27441    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27442        let mut snapshot = git_snapshot_for_parser();
27443        parse_git_status(
27444            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27445            &mut snapshot,
27446        );
27447
27448        assert!(!snapshot.truncated);
27449        assert_eq!(snapshot.status.len(), 3);
27450        assert_eq!(snapshot.status[0].index_status, "R");
27451        assert_eq!(snapshot.status[0].worktree_status, " ");
27452        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27453        assert_eq!(
27454            snapshot.status[0]
27455                .previous_path
27456                .as_ref()
27457                .and_then(RepositoryPath::as_utf8),
27458            Some("src/previous name.rs"),
27459        );
27460        assert_eq!(snapshot.status[1].index_status, " ");
27461        assert_eq!(snapshot.status[1].worktree_status, "C");
27462        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27463        assert_eq!(
27464            snapshot.status[1]
27465                .previous_path
27466                .as_ref()
27467                .and_then(RepositoryPath::as_utf8),
27468            Some("src/original.rs"),
27469        );
27470        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27471        assert!(snapshot.status[2].previous_path.is_none());
27472    }
27473
27474    #[test]
27475    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27476        let mut snapshot = git_snapshot_for_parser();
27477        parse_git_status(
27478            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27479            &mut snapshot,
27480        );
27481
27482        assert!(snapshot.truncated);
27483        assert_eq!(snapshot.status.len(), 1);
27484        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27485        assert!(snapshot.status[0].previous_path.is_none());
27486    }
27487
27488    #[tokio::test]
27489    async fn observer_can_browse_host_directories_without_controller() {
27490        let root = temporary_workspace_root("host-directory-observer");
27491        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27492        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27493        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27494        let workspace_id = WorkspaceId::new("primary").unwrap();
27495        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27496        let catalog = active_registry().unwrap();
27497        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27498        let shared = NodeShared::new(
27499            handle,
27500            "fixture-token".to_owned(),
27501            NodeId::new("node-1").unwrap(),
27502            vec![workspace],
27503            vec![agent("claude")],
27504        );
27505        let request = NodeRequest::BrowseHostDirectories {
27506            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27507            after: None,
27508        };
27509        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27510            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27511        }));
27512        assert!(!request_uses_unnegotiated_capability(
27513            &request,
27514            &baseline_capabilities().unwrap(),
27515        ));
27516        assert!(request_uses_unnegotiated_capability(&request, &[]));
27517
27518        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27519            .await
27520            .unwrap();
27521        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27522            panic!("host directory browse returned another response");
27523        };
27524        assert_eq!(
27525            listing
27526                .entries
27527                .iter()
27528                .map(|entry| entry.display_name.as_str())
27529                .collect::<Vec<_>>(),
27530            vec!["directory-a", "directory-b"],
27531        );
27532        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27533        assert!(!listing.incomplete);
27534        std::fs::remove_dir_all(root).unwrap();
27535    }
27536
27537    #[tokio::test]
27538    async fn observer_can_inspect_only_a_registered_workspace() {
27539        let root = temporary_workspace_root("observer");
27540        std::fs::create_dir_all(root.join("src")).unwrap();
27541        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27542        let workspace_id = WorkspaceId::new("primary").unwrap();
27543        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27544        let catalog = active_registry().unwrap();
27545        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27546        let shared = NodeShared::new(
27547            handle,
27548            "fixture-token".to_owned(),
27549            NodeId::new("node-1").unwrap(),
27550            vec![workspace],
27551            vec![agent("claude")],
27552        );
27553
27554        let response = process_request_inner(
27555            &shared,
27556            77,
27557            ClientRole::Observer,
27558            NodeRequest::InspectWorkspace {
27559                workspace_id: workspace_id.clone(),
27560            },
27561        )
27562        .await
27563        .unwrap();
27564        let NodeResponse::WorkspaceInspected { inspection } = response else {
27565            panic!("workspace inspection returned another response");
27566        };
27567        assert_eq!(inspection.workspace_id, workspace_id);
27568        assert!(inspection.entries.iter().any(|entry| {
27569            entry.relative_path.as_utf8() == Some("src/main.rs")
27570        }));
27571
27572        let unknown = process_request_inner(
27573            &shared,
27574            77,
27575            ClientRole::Observer,
27576            NodeRequest::InspectWorkspace {
27577                workspace_id: WorkspaceId::new("unknown").unwrap(),
27578            },
27579        )
27580        .await
27581        .unwrap_err();
27582        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27583        std::fs::remove_dir_all(&root).unwrap();
27584    }
27585
27586    #[test]
27587    fn context_pack_source_accepts_only_running_or_successful_exit() {
27588        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27589        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27590            exit_code: Some(0),
27591        }));
27592        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27593            exit_code: Some(1),
27594        }));
27595        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27596            exit_code: None,
27597        }));
27598        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27599            message: "provider failed".to_owned(),
27600        }));
27601        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27602        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27603        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27604    }
27605
27606    #[test]
27607    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27608        let git = git_snapshot_for_parser();
27609        let first = ContextPackRepositoryObservation {
27610            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27611            git: git.clone(),
27612            files: Vec::new(),
27613        };
27614        let second = ContextPackRepositoryObservation {
27615            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27616            git,
27617            files: Vec::new(),
27618        };
27619
27620        let error = stable_context_pack_repository(first, second).unwrap_err();
27621
27622        assert_eq!(
27623            error.code,
27624            NodeFailureCode::ContextPackMaterializationFailed,
27625        );
27626    }
27627
27628    #[test]
27629    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27630        let git = git_snapshot_for_parser();
27631        let first = ContextPackRepositoryObservation {
27632            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27633            git: git.clone(),
27634            files: vec![ContextPackRepositoryFileSource::utf8(
27635                "README.md",
27636                "first\n".to_owned(),
27637                6,
27638            )],
27639        };
27640        let second = ContextPackRepositoryObservation {
27641            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27642            git,
27643            files: vec![ContextPackRepositoryFileSource::utf8(
27644                "README.md",
27645                "other\n".to_owned(),
27646                6,
27647            )],
27648        };
27649
27650        let error = stable_context_pack_repository(first, second).unwrap_err();
27651
27652        assert_eq!(
27653            error.code,
27654            NodeFailureCode::ContextPackMaterializationFailed,
27655        );
27656    }
27657
27658    #[tokio::test]
27659    async fn native_session_catalog_zero_active_session_fixture() {
27660        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27661        let history_root = temporary_workspace_root("native-catalog-history");
27662        let outside_root = temporary_workspace_root("native-catalog-outside");
27663        std::fs::create_dir_all(&workspace_root).unwrap();
27664        std::fs::create_dir_all(&history_root).unwrap();
27665        std::fs::create_dir_all(&outside_root).unwrap();
27666        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27667            [
27668                serde_json::json!({
27669                    "type": "user",
27670                    "sessionId": session_id,
27671                    "cwd": cwd.to_str().unwrap(),
27672                    "message": { "content": "raw user text must not cross the catalog wire" }
27673                }),
27674                serde_json::json!({
27675                    "type": "assistant",
27676                    "sessionId": session_id,
27677                    "cwd": cwd.to_str().unwrap(),
27678                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27679                }),
27680                serde_json::json!({
27681                    "type": "ai-title",
27682                    "sessionId": session_id,
27683                    "aiTitle": title
27684                }),
27685            ]
27686            .into_iter()
27687            .map(|record| record.to_string())
27688            .collect::<Vec<_>>()
27689            .join("\n")
27690        };
27691        std::fs::write(
27692            history_root.join("stable-session.jsonl"),
27693            transcript("stable-session", &workspace_root, "Workspace review"),
27694        )
27695        .unwrap();
27696        std::fs::write(
27697            history_root.join("outside-session.jsonl"),
27698            transcript("outside-session", &outside_root, "Outside review"),
27699        )
27700        .unwrap();
27701        let history = NativeHistoryConfig::new(vec![
27702            NativeHistoryRoot::new(
27703                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27704                HistorySourceLayout::SingleNdjson,
27705                history_root.clone(),
27706            )
27707            .unwrap(),
27708        ])
27709        .unwrap();
27710        let catalog = active_registry().unwrap();
27711        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27712        assert!(handle.snapshot().sessions.is_empty());
27713        let workspace_id = WorkspaceId::new("primary").unwrap();
27714        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27715        let mut shared = NodeShared::new(
27716            handle,
27717            "fixture-token".to_owned(),
27718            NodeId::new("node-native-catalog").unwrap(),
27719            vec![workspace],
27720            vec![agent("claude")],
27721        );
27722        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27723            NativeSessionCatalogAuthority::new(history),
27724        )));
27725        let route = NativeSessionCatalogRoute::workspace(
27726            workspace_id.clone(),
27727            agent("claude"),
27728        );
27729        let request = NodeRequest::CatalogNativeSessions {
27730            route: route.clone(),
27731            limit: 2,
27732        };
27733        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27734            .await
27735            .unwrap_err();
27736        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27737        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27738            .await
27739            .unwrap();
27740        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27741            panic!("native catalog returned a different response");
27742        };
27743        let summary = summary.expect("node must emit native session catalog summary metadata");
27744        assert_eq!(entries.len(), 1);
27745        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27746        assert_eq!(entries[0].external_group, None);
27747        assert_eq!(entries[0].record_id, None);
27748        assert_eq!(entries[0].title, None);
27749        assert_eq!(entries[0].model, None);
27750        assert_eq!(entries[0].message_count, 0);
27751        let encoded_entries = serde_json::to_string(&entries).unwrap();
27752        assert!(!encoded_entries.contains("stable-session"));
27753        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27754        let page_request = NodeRequest::PageNativeSessions {
27755            route: route.clone(),
27756            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27757            catalog_revision: summary.catalog_revision,
27758            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27759            after_selection_id: None,
27760            limit: 2,
27761        };
27762        let observer = process_request_inner(
27763            &shared,
27764            7,
27765            ClientRole::Observer,
27766            page_request.clone(),
27767        )
27768        .await
27769        .unwrap_err();
27770        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27771        let page_response = process_request_inner(
27772            &shared,
27773            7,
27774            ClientRole::Operator,
27775            page_request.clone(),
27776        )
27777        .await
27778        .unwrap();
27779        assert!(matches!(
27780            page_response,
27781            NodeResponse::NativeSessionsPaged { page, .. }
27782                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27783        ));
27784        let stale = process_request_inner(
27785            &shared,
27786            7,
27787            ClientRole::Operator,
27788            NodeRequest::PageNativeSessions {
27789                route: route.clone(),
27790                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27791                catalog_revision: summary.catalog_revision.wrapping_add(1),
27792                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27793                after_selection_id: None,
27794                limit: 2,
27795            },
27796        )
27797        .await
27798        .unwrap_err();
27799        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27800        let selection = NativeSessionSelection {
27801            route: route.clone(),
27802            catalog_revision: summary.catalog_revision,
27803            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27804            selection_id: entries[0].selection_id.clone(),
27805        };
27806        let preview_response = process_request_inner(
27807            &shared,
27808            7,
27809            ClientRole::Operator,
27810            NodeRequest::PreviewNativeSession {
27811                selection: selection.clone(),
27812                message_limit: 1,
27813            },
27814        )
27815        .await
27816        .unwrap();
27817        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27818            panic!("native preview returned a different response");
27819        };
27820        assert_eq!(preview.messages.len(), 1);
27821        assert!(preview.message_count_exact);
27822        assert_eq!(preview.messages[0].text, "raw answer");
27823        assert!(preview.truncated);
27824
27825        let index_request = NodeRequest::IndexNativeSession {
27826            selection: selection.clone(),
27827            display_name: "Saved Claude".to_owned(),
27828        };
27829        let observer = process_request_inner(
27830            &shared,
27831            7,
27832            ClientRole::Observer,
27833            index_request.clone(),
27834        )
27835        .await
27836        .unwrap_err();
27837        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27838        let without_controller = process_request_inner(
27839            &shared,
27840            7,
27841            ClientRole::Operator,
27842            index_request.clone(),
27843        )
27844        .await
27845        .unwrap_err();
27846        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27847        let acquired = process_request_inner(
27848            &shared,
27849            7,
27850            ClientRole::Operator,
27851            NodeRequest::AcquireController { lease_ms: 5_000 },
27852        )
27853        .await
27854        .unwrap();
27855        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27856        let indexed = process_request_inner(
27857            &shared,
27858            7,
27859            ClientRole::Operator,
27860            index_request,
27861        )
27862        .await
27863        .unwrap();
27864        assert!(indexed.native_session_index_contract_is_valid());
27865        let NodeResponse::NativeSessionIndexed {
27866            selection: echoed_selection,
27867            record,
27868        } = indexed else {
27869            panic!("native session index returned a different response");
27870        };
27871        assert_eq!(echoed_selection, selection);
27872        let recataloged = process_request_inner(
27873            &shared,
27874            7,
27875            ClientRole::Operator,
27876            NodeRequest::CatalogNativeSessions {
27877                route,
27878                limit: 2,
27879            },
27880        )
27881        .await
27882        .unwrap();
27883        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27884            panic!("native catalog returned a different response after indexing");
27885        };
27886        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27887
27888        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27889        let external_catalog = process_request_inner(
27890            &shared,
27891            7,
27892            ClientRole::Operator,
27893            NodeRequest::CatalogNativeSessions {
27894                route: unregistered_route.clone(),
27895                limit: 2,
27896            },
27897        )
27898        .await
27899        .unwrap();
27900        let external_json = serde_json::to_string(&external_catalog).unwrap();
27901        assert!(!external_json.contains("outside-session"));
27902        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27903        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27904        assert!(!external_json.contains("\"session_id\""));
27905        assert!(!external_json.contains("\"cwd\""));
27906        assert!(!external_json.contains("\"path\""));
27907        let NodeResponse::NativeSessionsCataloged {
27908            route: echoed_route,
27909            entries: external_entries,
27910            summary: Some(external_summary),
27911        } = external_catalog
27912        else {
27913            panic!("unregistered native catalog returned a different response");
27914        };
27915        assert_eq!(echoed_route, unregistered_route);
27916        assert_eq!(external_entries.len(), 1);
27917        assert_eq!(external_entries[0].record_id, None);
27918        let external_group = external_entries[0]
27919            .external_group
27920            .as_ref()
27921            .expect("unregistered session must have an external group");
27922        assert_eq!(
27923            external_group.kind,
27924            gate4agent_types::NativeSessionExternalGroupKind::Project,
27925        );
27926        assert!(external_group.group_id.starts_with("external-"));
27927        assert_eq!(
27928            external_group.display_name,
27929            outside_root.file_name().unwrap().to_string_lossy(),
27930        );
27931        let external_selection = NativeSessionSelection {
27932            route: unregistered_route,
27933            catalog_revision: external_summary.catalog_revision,
27934            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27935            selection_id: external_entries[0].selection_id.clone(),
27936        };
27937        let external_preview = process_request_inner(
27938            &shared,
27939            7,
27940            ClientRole::Operator,
27941            NodeRequest::PreviewNativeSession {
27942                selection: external_selection.clone(),
27943                message_limit: 1,
27944            },
27945        )
27946        .await
27947        .unwrap();
27948        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27949        assert!(!external_preview_json.contains("outside-session"));
27950        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27951        assert!(!external_preview_json.contains("\"session_id\""));
27952        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27953            panic!("unregistered native preview returned a different response");
27954        };
27955        assert_eq!(preview.messages.len(), 1);
27956        let external_index = process_request_inner(
27957            &shared,
27958            7,
27959            ClientRole::Operator,
27960            NodeRequest::IndexNativeSession {
27961                selection: external_selection,
27962                display_name: "External Claude".to_owned(),
27963            },
27964        )
27965        .await
27966        .unwrap_err();
27967        assert_eq!(
27968            external_index.code,
27969            NodeFailureCode::WorkspaceRegistrationRequired,
27970        );
27971        let record_response = process_request_inner(
27972            &shared,
27973            7,
27974            ClientRole::Operator,
27975            NodeRequest::PreviewSessionRecord {
27976                record_id: record.record_id.clone(),
27977                message_limit: 2,
27978            },
27979        )
27980        .await
27981        .unwrap();
27982        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27983            panic!("record preview returned a different response");
27984        };
27985        assert_eq!(record_id, record.record_id);
27986        assert_eq!(preview.messages.len(), 2);
27987        let encoded = serde_json::to_string(&preview).unwrap();
27988        assert!(!encoded.contains("stable-session"));
27989        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27990        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27991            panic!("record preview history summary returned a different response");
27992        };
27993        let summaries = events
27994            .iter()
27995            .filter_map(|envelope| match &envelope.event {
27996                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27997                    if record_id == &record.record_id =>
27998                {
27999                    Some(*summary)
28000                }
28001                _ => None,
28002            })
28003            .collect::<Vec<_>>();
28004        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28005        let summary_json = serde_json::to_string(&summaries).unwrap();
28006        for private in [
28007            "stable-session",
28008            "raw user text must not cross the catalog wire",
28009            "raw answer",
28010            "Workspace review",
28011            "claude-sonnet",
28012        ] {
28013            assert!(!summary_json.contains(private), "{private}");
28014        }
28015        let identity = record.provider_session.clone().unwrap();
28016        assert_eq!(
28017            shared.revalidate_session_record_preview(&record, &identity),
28018            Ok(())
28019        );
28020        shared.remove_record_memory(&record.record_id);
28021        assert_eq!(
28022            shared
28023                .revalidate_session_record_preview(&record, &identity)
28024                .unwrap_err()
28025                .code,
28026            NodeFailureCode::UnknownSessionRecord
28027        );
28028        let released = process_request_inner(
28029            &shared,
28030            7,
28031            ClientRole::Operator,
28032            NodeRequest::ReleaseController,
28033        )
28034        .await
28035        .unwrap();
28036        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28037        assert!(shared.controller_state().is_none());
28038        std::fs::remove_dir_all(workspace_root).unwrap();
28039        std::fs::remove_dir_all(history_root).unwrap();
28040        std::fs::remove_dir_all(outside_root).unwrap();
28041    }
28042
28043    #[tokio::test]
28044    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28045        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28046        std::fs::create_dir_all(&history_root).unwrap();
28047        let history = NativeHistoryConfig::new(vec![
28048            NativeHistoryRoot::new(
28049                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28050                HistorySourceLayout::SingleNdjson,
28051                history_root.clone(),
28052            )
28053            .unwrap(),
28054        ])
28055        .unwrap();
28056        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28057        let held = catalog.lock().unwrap();
28058        let result = timeout(
28059            Duration::from_secs(1),
28060            run_native_session_catalog_operation(
28061                Arc::clone(&catalog),
28062                "native session catalog",
28063                |_| Ok::<(), std::convert::Infallible>(()),
28064            ),
28065        )
28066        .await
28067        .expect("concurrent native catalog request must not wait for the operation deadline")
28068        .unwrap_err();
28069        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28070        drop(held);
28071        std::fs::remove_dir_all(history_root).unwrap();
28072    }
28073
28074    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28075        let unique = std::time::SystemTime::now()
28076            .duration_since(std::time::UNIX_EPOCH)
28077            .unwrap()
28078            .as_nanos();
28079        std::env::temp_dir().join(format!(
28080            "gate4agent-node-{label}-{}-{unique}",
28081            std::process::id(),
28082        ))
28083    }
28084
28085    fn git_snapshot_for_parser() -> GitSnapshot {
28086        GitSnapshot {
28087            is_repository: true,
28088            branch: Some("main".to_owned()),
28089            status: Vec::new(),
28090            recent_commits: Vec::new(),
28091            worktrees: Vec::new(),
28092            managed_worktree: None,
28093            truncated: false,
28094            diagnostic: None,
28095        }
28096    }
28097
28098    #[tokio::test]
28099    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28100        let root = temporary_workspace_root("managed-git-scope");
28101        std::fs::create_dir_all(&root).unwrap();
28102        run_test_git(&root, &["init"]);
28103        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28104        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28105        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28106        run_test_git(&root, &["add", "seed.txt"]);
28107        run_test_git(&root, &["commit", "-m", "seed"]);
28108        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28109
28110        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28111        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28112        let catalog = active_registry().unwrap();
28113        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28114        let shared = NodeShared::new(
28115            handle,
28116            "fixture-token".to_owned(),
28117            NodeId::new("node-1").unwrap(),
28118            vec![workspace],
28119            vec![agent("claude")],
28120        );
28121        let mut lease = ManagedWorktreeLeaseRecord {
28122            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28123            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28124            workspace_id: workspace_id.clone(),
28125            profile_id: WorktreeProfileId::new("default").unwrap(),
28126            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28127            target_root: root.to_string_lossy().into_owned(),
28128            branch: "gate4agent/a".to_owned(),
28129            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28130            expected_head: None,
28131            retention: ManagedWorktreeRetention::Retain,
28132            state: ManagedWorktreeLeaseState::InUse,
28133            session_holders: vec![ManagedWorktreeSessionHolder {
28134                incarnation_id: shared.incarnation_id,
28135                instance_id: AgentInstanceId(7),
28136                generation: SessionGeneration(1),
28137            }],
28138            record_holders: Vec::new(),
28139            cleanup_failure: None,
28140            created_at_unix_ms: 1,
28141            updated_at_unix_ms: 1,
28142        };
28143        *shared
28144            .managed_worktrees
28145            .lock()
28146            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28147            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28148
28149        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28150        let scope = inspection.git.managed_worktree.unwrap();
28151        assert_eq!(scope.lease_id, lease.lease_id);
28152        assert_eq!(scope.branch, "gate4agent/a");
28153        assert_eq!(scope.active_session_count, 1);
28154
28155        lease.branch = "gate4agent/b".to_owned();
28156        *shared
28157            .managed_worktrees
28158            .lock()
28159            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28160            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28161        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28162        assert!(mismatch.git.managed_worktree.is_none());
28163        std::fs::remove_dir_all(root).unwrap();
28164    }
28165
28166    #[tokio::test]
28167    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28168        let root = temporary_workspace_root("workspace-file-write-controller");
28169        std::fs::create_dir_all(root.join("src")).unwrap();
28170        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28171        let workspace_id = WorkspaceId::new("primary").unwrap();
28172        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28173        let catalog = active_registry().unwrap();
28174        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28175        let shared = NodeShared::new(
28176            handle,
28177            "fixture-token".to_owned(),
28178            NodeId::new("node-1").unwrap(),
28179            vec![workspace],
28180            vec![agent("claude")],
28181        );
28182        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28183        let expected_revision = workspace_file_revision(b"old\n");
28184        let request = NodeRequest::WriteWorkspaceFile {
28185            workspace_id: workspace_id.clone(),
28186            path: path.clone(),
28187            expected_revision: expected_revision.clone(),
28188            text: "new\n".to_owned(),
28189        };
28190        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28191            .await
28192            .unwrap_err();
28193        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28194        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28195            .await
28196            .unwrap_err();
28197        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28198        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28199        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28200        let NodeResponse::WorkspaceFileWritten { file } = response else {
28201            panic!("workspace file write returned another response");
28202        };
28203        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28204        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28205        let stale = process_request_inner(
28206            &shared,
28207            77,
28208            ClientRole::Operator,
28209            NodeRequest::WriteWorkspaceFile {
28210                workspace_id,
28211                path,
28212                expected_revision,
28213                text: "lost update\n".to_owned(),
28214            },
28215        )
28216        .await
28217        .unwrap_err();
28218        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28219        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28220        std::fs::remove_dir_all(root).unwrap();
28221    }
28222
28223    #[tokio::test]
28224    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28225        let root = temporary_workspace_root("workspace-entry-create-controller");
28226        std::fs::create_dir_all(root.join("src")).unwrap();
28227        let workspace_id = WorkspaceId::new("primary").unwrap();
28228        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28229        let catalog = active_registry().unwrap();
28230        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28231        let shared = NodeShared::new(
28232            handle,
28233            "fixture-token".to_owned(),
28234            NodeId::new("node-1").unwrap(),
28235            vec![workspace],
28236            vec![agent("claude")],
28237        );
28238        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28239        let request = NodeRequest::CreateWorkspaceFile {
28240            workspace_id: workspace_id.clone(),
28241            path: path.clone(),
28242        };
28243        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28244            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28245        }));
28246        assert!(!request_uses_unnegotiated_capability(
28247            &request,
28248            &baseline_capabilities().unwrap(),
28249        ));
28250        assert!(request_uses_unnegotiated_capability(&request, &[]));
28251        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28252            .await
28253            .unwrap_err();
28254        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28255        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28256            .await
28257            .unwrap_err();
28258        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28259        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28260
28261        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262            .await
28263            .unwrap();
28264        let NodeResponse::WorkspaceFileCreated { file } = response else {
28265            panic!("workspace file create returned another response");
28266        };
28267        assert_eq!(file.workspace_id, workspace_id);
28268        assert_eq!(file.path, path);
28269        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28270        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28271
28272        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28273            .await
28274            .unwrap_err();
28275        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28276
28277        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28278        let response = process_request_inner(
28279            &shared,
28280            77,
28281            ClientRole::Operator,
28282            NodeRequest::CreateWorkspaceDirectory {
28283                workspace_id: workspace_id.clone(),
28284                path: directory_path.clone(),
28285            },
28286        )
28287        .await
28288        .unwrap();
28289        assert_eq!(
28290            response,
28291            NodeResponse::WorkspaceDirectoryCreated {
28292                workspace_id: workspace_id.clone(),
28293                entry: WorkspaceEntry {
28294                    relative_path: directory_path,
28295                    kind: WorkspaceEntryKind::Directory,
28296                },
28297            },
28298        );
28299        assert!(root.join("src/nested").is_dir());
28300
28301        let missing_parent = process_request_inner(
28302            &shared,
28303            77,
28304            ClientRole::Operator,
28305            NodeRequest::CreateWorkspaceFile {
28306                workspace_id: workspace_id.clone(),
28307                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28308            },
28309        )
28310        .await
28311        .unwrap_err();
28312        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28313        let non_directory_parent = process_request_inner(
28314            &shared,
28315            77,
28316            ClientRole::Operator,
28317            NodeRequest::CreateWorkspaceFile {
28318                workspace_id,
28319                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28320            },
28321        )
28322        .await
28323        .unwrap_err();
28324        assert_eq!(
28325            non_directory_parent.code,
28326            NodeFailureCode::RepositoryParentNotDirectory,
28327        );
28328        std::fs::remove_dir_all(root).unwrap();
28329    }
28330
28331    #[tokio::test]
28332    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28333        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28334        std::fs::create_dir_all(root.join("src")).unwrap();
28335        #[cfg(windows)]
28336        let root = std::fs::canonicalize(root).unwrap();
28337        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28338        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28339        let barrier = Arc::new(std::sync::Barrier::new(2));
28340        let done = Arc::new(AtomicBool::new(false));
28341        let task_commit_state = Arc::clone(&commit_state);
28342        let task_barrier = Arc::clone(&barrier);
28343        let task_done = Arc::clone(&done);
28344        let task_root = root.clone();
28345        let task = tokio::task::spawn_blocking(move || {
28346            task_barrier.wait();
28347            let result = create_workspace_file_on_disk(
28348                &task_root,
28349                &path,
28350                &task_commit_state,
28351            );
28352            task_done.store(true, Ordering::Release);
28353            result
28354        });
28355
28356        let error = settle_workspace_entry_create(
28357            task,
28358            Arc::clone(&commit_state),
28359            Duration::from_millis(10),
28360            "fixture deadline",
28361            "fixture task failed",
28362        )
28363        .await
28364        .unwrap_err();
28365        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28366        assert_eq!(
28367            commit_state.load(Ordering::Acquire),
28368            WORKSPACE_ENTRY_CREATE_CANCELED,
28369        );
28370
28371        barrier.wait();
28372        timeout(Duration::from_secs(1), async {
28373            while !done.load(Ordering::Acquire) {
28374                tokio::task::yield_now().await;
28375            }
28376        })
28377        .await
28378        .expect("canceled blocking create did not settle");
28379        assert!(!root.join("src/late.rs").exists());
28380        std::fs::remove_dir_all(root).unwrap();
28381    }
28382}
28383
28384#[cfg(test)]
28385mod provisional_spawn_tests {
28386    use super::*;
28387    use std::sync::Arc;
28388
28389    fn shared() -> NodeShared {
28390        let catalog = active_registry().unwrap();
28391        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28392        let workspace = WorkspaceConfig::new(
28393            WorkspaceId::new("primary").unwrap(),
28394            std::env::current_dir().unwrap(),
28395        )
28396        .unwrap()
28397        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28398        NodeShared::new(
28399            handle,
28400            "fixture-token".to_owned(),
28401            NodeId::new("node-provisional-test").unwrap(),
28402            vec![workspace],
28403            vec![AgentId::new("claude").unwrap()],
28404        )
28405    }
28406
28407    /// Provisional draft map: sessionize by instance id only; 50 sequential
28408    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28409    #[test]
28410    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28411        let shared = shared();
28412        let ws = WorkspaceId::new("primary").unwrap();
28413        let mut ids = Vec::with_capacity(50);
28414        for i in 1u64..=50 {
28415            let id = AgentInstanceId(i);
28416            ids.push(id);
28417            let address = SessionAddress {
28418                workspace_id: ws.clone(),
28419                session: SessionKey {
28420                    instance_id: id,
28421                    generation: SessionGeneration(1),
28422                },
28423            };
28424            shared.mark_spawn_provisional(address);
28425        }
28426        assert_eq!(
28427            shared
28428                .provisional_spawns
28429                .lock()
28430                .unwrap_or_else(|p| p.into_inner())
28431                .len(),
28432            50
28433        );
28434        for id in ids.iter().step_by(2) {
28435            shared.sessionize_spawn(*id);
28436        }
28437        assert_eq!(
28438            shared
28439                .provisional_spawns
28440                .lock()
28441                .unwrap_or_else(|p| p.into_inner())
28442                .len(),
28443            25
28444        );
28445        assert!(shared.take_due_provisional_spawns().is_empty());
28446        {
28447            let mut map = shared
28448                .provisional_spawns
28449                .lock()
28450                .unwrap_or_else(|p| p.into_inner());
28451            let past = Instant::now() - Duration::from_secs(91);
28452            for (_, (_, at)) in map.iter_mut() {
28453                *at = past;
28454            }
28455        }
28456        let due = shared.take_due_provisional_spawns();
28457        assert_eq!(due.len(), 25);
28458        let due_ids: std::collections::BTreeSet<_> =
28459            due.iter().map(|a| a.session.instance_id.0).collect();
28460        let expected: std::collections::BTreeSet<_> =
28461            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28462        assert_eq!(due_ids, expected);
28463        assert!(shared
28464            .provisional_spawns
28465            .lock()
28466            .unwrap_or_else(|p| p.into_inner())
28467            .is_empty());
28468    }
28469
28470    /// Twenty parallel provisional marks must each get their own instance key;
28471    /// killing/sessionizing one id must not touch the others.
28472    #[test]
28473    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28474        let shared = Arc::new(shared());
28475        let ws = WorkspaceId::new("primary").unwrap();
28476        let mut handles = Vec::new();
28477        for i in 1u64..=20 {
28478            let shared = Arc::clone(&shared);
28479            let ws = ws.clone();
28480            handles.push(std::thread::spawn(move || {
28481                let id = AgentInstanceId(i);
28482                let address = SessionAddress {
28483                    workspace_id: ws,
28484                    session: SessionKey {
28485                        instance_id: id,
28486                        generation: SessionGeneration(1),
28487                    },
28488                };
28489                shared.mark_spawn_provisional(address);
28490                id
28491            }));
28492        }
28493        let mut ids = Vec::new();
28494        for h in handles {
28495            ids.push(h.join().unwrap());
28496        }
28497        ids.sort_by_key(|id| id.0);
28498        assert_eq!(ids.len(), 20);
28499        ids.dedup();
28500        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28501        assert_eq!(
28502            shared
28503                .provisional_spawns
28504                .lock()
28505                .unwrap_or_else(|p| p.into_inner())
28506                .len(),
28507            20
28508        );
28509        shared.sessionize_spawn(AgentInstanceId(7));
28510        let left: Vec<_> = shared
28511            .provisional_spawns
28512            .lock()
28513            .unwrap_or_else(|p| p.into_inner())
28514            .keys()
28515            .map(|id| id.0)
28516            .collect();
28517        assert!(!left.contains(&7));
28518        assert_eq!(left.len(), 19);
28519    }
28520}
28521
28522
28523#[cfg(unix)]
28524extern "C" {
28525    #[link_name = "geteuid"]
28526    fn libc_geteuid() -> u32;
28527}
28528
28529#[cfg(test)]
28530mod call_home_link_tests {
28531    use super::*;
28532
28533    #[test]
28534    fn call_home_backoff_is_capped_and_monotonic() {
28535        let mut last = Duration::ZERO;
28536        for attempt in 1..20 {
28537            let next = call_home_backoff(attempt);
28538            assert!(next >= last);
28539            assert!(next <= Duration::from_secs(8));
28540            last = next;
28541        }
28542        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28543    }
28544
28545    #[tokio::test]
28546    async fn call_home_socket_gets_keepalive() {
28547        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28548        let addr = listener.local_addr().unwrap();
28549        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28550        let client = client.unwrap();
28551        enable_call_home_keepalive(&client).unwrap();
28552        let sock = socket2::SockRef::from(&client);
28553        assert!(sock.keepalive().unwrap());
28554        #[cfg(any(target_os = "linux", target_os = "macos"))]
28555        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28556    }
28557}
28558
28559/// A provider transcript for this session id exists in a known provider home.
28560fn provider_session_exists(provider: &str, id: &str) -> bool {
28561    if provider != "claude" { return false; }
28562    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28563    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28564    let name = format!("{id}.jsonl");
28565    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())))
28566}
28567
28568/// Append launch args to a prepared overlay (or create an instance overlay for them).
28569fn overlay_with_extra_args(
28570    provider: &AgentId,
28571    mode: SessionMode,
28572    overlay: Option<PreparedNativeLaunchOverlay>,
28573    args: Vec<OsString>,
28574) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28575    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28576    Ok(match (overlay, args) {
28577        (prepared, args) if args.is_empty() => prepared,
28578        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28579        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28580        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28581            provider.clone(),
28582            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28583            Vec::new(),
28584            args,
28585        ).map_err(|_| bad())?)),
28586    })
28587}