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        .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222    /// Model named by the spawn request (any launch path, ACP included), for the m4a auto-nick
223    /// that is registered before the ACP options are applied.
224    static SPAWN_NICK_MODEL: Option<String>;
225    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
226    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
227    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228    /// Provider session id to resume (node-start restore of journalled sessions).
229    static RESUME_PROVIDER_SESSION: Option<String>;
230    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
231    static SPAWN_ORIGIN: Option<String>;
232    static SPAWN_PARENT: Option<String>;
233}
234
235/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
236fn spawn_selection(
237    options: &gate4agent_node_protocol::SpawnSessionOptions,
238) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
239    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
240    match (&options.model, &options.effort) {
241        (None, None) => Ok(None),
242        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
243        (Some(model), effort) => {
244            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
245            if let Some(e) = effort {
246                sel = sel.with_value("effort", e.clone());
247            }
248            Ok(Some(sel))
249        }
250    }
251}
252
253mod browser_bridge;
254
255use gate4agent_node_core::drive_loop_profile;
256
257#[path = "bundle_delivery.rs"]
258mod bundle_delivery;
259
260#[path = "context_pack_store.rs"]
261mod context_pack_store;
262
263#[cfg(windows)]
264pub use crate::platform::DEFAULT_NODE_ENDPOINT;
265
266const NODE_EVENT_HISTORY_MAX: usize = 4_096;
267const NODE_BROADCAST_CAPACITY: usize = 1_024;
268const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
269/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
270/// terminal frame is a full-screen snapshot; only the latest ever matters),
271/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
272/// two pending sends into one would silently drop content a subscriber can
273/// never recover. This needs headroom for a burst instead.
274const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
275/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
276/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
277/// admission check), and `publish_transient` sends each exactly once as a
278/// single-envelope batch -- so this many slots covers the entire pending
279/// set without ever lagging the one subscriber that matters, the harness's
280/// own connection, the way sharing the durable `event_tx`'s bus could.
281const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
282const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
283const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
284const MAX_PREAUTH_CONNECTIONS: usize = 32;
285const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
286const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
287const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
288const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
289const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
290/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
291/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
292/// not the disconnect reconnect grace (HQ owns that, starting only on a real
293/// client WS close). A provider conversation id is a session; a PTY with no
294/// attached HQ view is still a draft and is reaped here if attach never lands.
295const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
296/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
297/// record retention policy (`NodeServerConfig::session_record_retention`).
298/// A sweep cadence, not a policy value -- the policy itself lives entirely
299/// in the operator-configured age/keep-N flags; this just bounds how stale
300/// the in-memory view of dead `Unavailable` records can get between sweeps
301/// on an otherwise-idle node, mirroring the once-per-startup sweep that
302/// also runs alongside `reconcile_managed_worktrees`.
303const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
304/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
305/// field's own doc for why a spawn waiter needs this at all. Must outlive
306/// the longest a waiter can still be polling for this exact command's
307/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
308/// largest `deadline_ms` any caller may request, and the margin on top
309/// absorbs the waiter's own poll cadence plus clock skew between the drive
310/// loop (writer) and the waiter (reader).
311const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
312/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
313/// backlog of asynchronously-rejected commands nobody ever collects (a
314/// waiter that stops polling, or a burst of always-rejected commands from a
315/// misbehaving caller): once at capacity, the oldest entry is evicted to
316/// make room for the newest, mirroring the eviction rule already used by
317/// `spawn_idempotency`.
318const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
319/// How long `wait_until_removed` waits between two `Remove` re-dispatches
320/// for the same session. The presence poll runs far faster (2ms); this
321/// only paces the sending, so an accepted `Remove` still in flight is not
322/// piled on by a dozen more that land on an already-dropped instance.
323const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
324const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
325const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
326const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
327const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
328const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
329const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
331const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
332const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
333const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
334const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
335const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
336/// Default total wall-clock budget for `inspect_workspace`'s walk+git
337/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
338const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
339/// The harness relay enforces its own flat response deadline
340/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
341/// 12s) above this node-side budget. The two constants live in different
342/// crates with no shared definition, so clamp any override here comfortably
343/// below that external ceiling — a misconfigured value above it would
344/// reopen the exact deadline race this budget exists to close.
345const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
346/// Default cap on directory entries visited (not just returned) by one
347/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
348const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
349const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
350const GIT_STATUS_MAX_ENTRIES: usize = 128;
351const GIT_COMMIT_MAX_ENTRIES: usize = 12;
352const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
353const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
354const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
355const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
356const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
357const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
358const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
359const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
360const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
361const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
362    "provider-session-identity-allocation-failed";
363const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
364const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
365const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
366const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
367const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
368const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
369const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
370    "durable-state-path-semantics-unsupported";
371
372fn opaque_windows_path(value: String) -> OpaqueHostPath {
373    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
374}
375
376fn agent_progress_from_provider_snapshot(
377    address: SessionAddress,
378    provider: &ProviderSnapshot,
379) -> Option<SessionAgentProgress> {
380    let mut truncated = false;
381    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
382    let mut active_tool_labels = Vec::with_capacity(
383        provider
384            .active_tools
385            .len()
386            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
387    );
388    for tool in &provider.active_tools {
389        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
390            truncated = true;
391            break;
392        }
393        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
394            active_tool_labels.push(label);
395        }
396    }
397    let attention = provider
398        .interactions
399        .iter()
400        .find(|interaction| {
401            matches!(
402                interaction.status,
403                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
404            )
405        })
406        .map(|interaction| AgentProgressAttentionV1 {
407            kind: match interaction.interaction_kind {
408                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
409                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
410            },
411            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
412        });
413    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
414    let usage = (provider.completed_turns > 0
415        || provider.usage.input_tokens > 0
416        || provider.usage.output_tokens > 0
417        || provider.usage.cache_read_tokens > 0
418        || provider.usage.cache_write_tokens > 0
419        || provider.usage.reasoning_tokens > 0)
420        .then_some(AgentProgressUsageV1 {
421            input_tokens: provider.usage.input_tokens,
422            output_tokens: provider.usage.output_tokens,
423            cache_read_tokens: provider.usage.cache_read_tokens,
424            cache_write_tokens: provider.usage.cache_write_tokens,
425            reasoning_tokens: provider.usage.reasoning_tokens,
426        });
427    let progress = AgentProgressV1 {
428        provider_sequence: provider.sequence,
429        activity: provider.activity,
430        completed_turns: provider.completed_turns,
431        usage,
432        current: AgentProgressCurrentV1::from(provider.activity),
433        active_tool_labels,
434        active_tool_count,
435        attention,
436        subagent_count,
437        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
438        gap_count: provider.gap_count,
439        stale: provider.stale,
440        truncated,
441    };
442    let entry = SessionAgentProgress { address, progress };
443    serde_json::to_vec(&entry)
444        .ok()
445        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
446        .map(|_| entry)
447}
448
449fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
450    match u32::try_from(count) {
451        Ok(count) => count,
452        Err(_) => {
453            *truncated = true;
454            u32::MAX
455        }
456    }
457}
458
459fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
460    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
461        return None;
462    }
463    Some(match event {
464        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
465        ProviderEvent::SessionIdentityObserved { .. } => {
466            AgentProgressEventKindV1::SessionIdentityObserved
467        }
468        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
469        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
470        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
471        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
472        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
473        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
474        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
475        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
476        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
477        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
478        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
479        ProviderEvent::InteractionRequested { .. } => {
480            AgentProgressEventKindV1::InteractionRequested
481        }
482        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
483        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
484        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
485        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
486        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
487        ProviderEvent::UnrecognizedNotification { .. } => {
488            AgentProgressEventKindV1::UnrecognizedNotification
489        }
490        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
491        // ACP session/update coverage beyond text/tool/turn streaming
492        // (`plan`, `available_commands_update`, `current_mode_update`,
493        // `session_info_update`, `usage_update`, `config_option_update`,
494        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
495        // wire enum without a `#[serde(other)]` fallback; minting new
496        // variants for it is a deliberate wire-contract decision for
497        // whoever owns this protocol, not a side effect of parsing more of
498        // ACP's own wire -- until that decision is made, these fold into
499        // the same "we saw something, no dedicated kind for it yet" bucket
500        // as `UnrecognizedNotification`.
501        ProviderEvent::UserMessage { .. }
502        | ProviderEvent::Plan { .. }
503        | ProviderEvent::AvailableCommandsUpdated { .. }
504        | ProviderEvent::ModeChanged { .. }
505        | ProviderEvent::SessionInfoUpdated { .. }
506        | ProviderEvent::UsageUpdated { .. }
507        | ProviderEvent::ConfigOptionsUpdated { .. } => {
508            AgentProgressEventKindV1::UnrecognizedNotification
509        }
510    })
511}
512
513/// Maps this harness's own `HostDecisionAuthority` onto the wider
514/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
515/// chunk carries -- the four authorities a `Denied` decision is ever made
516/// under, one-for-one. The remaining `BlockAuthorityV1` variants
517/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
518/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
519/// block the PROVIDER side of the wire reported -- this function never
520/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
521/// them; the provider-side blocks below mint those directly from typed fields.
522/// Whether a provider source is a live session transport -- the only kind
523/// whose events reach the agent stream. History, resume, session-option and
524/// capability-probe sources are not sessions and stream nothing.
525fn provider_family_is_live(family: AdapterFamily) -> bool {
526    match family {
527        AdapterFamily::PtySemantic
528        | AdapterFamily::Hook
529        | AdapterFamily::ManagedHook
530        | AdapterFamily::Pipe
531        | AdapterFamily::OneShot
532        | AdapterFamily::Acp => true,
533        AdapterFamily::History
534        | AdapterFamily::Resume
535        | AdapterFamily::SessionOptions
536        | AdapterFamily::CapabilityProbe => false,
537    }
538}
539
540fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
541    match by {
542        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
543        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
544        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
545        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
546    }
547}
548
549/// The `Blocked::reason` for a `Denied` decision this build's own
550/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
551/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
552/// request_permission` request resolved by `AcpSession::
553/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
554/// neither of which computes free text (an operator answers approve/reject,
555/// never a comment; an unattended deadline asks nobody anything). `reason`
556/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
557/// -- a categorical fallback naming the authority is the
558/// honest minimum, never a placeholder standing in for detail nobody
559/// reported.
560fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
561    match authority {
562        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
563        BlockAuthorityV1::HostPolicy => "denied by host policy",
564        BlockAuthorityV1::HostDeadline => {
565            "denied by host policy after the operator deadline expired"
566        }
567        BlockAuthorityV1::Operator => "declined by the operator",
568        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
569        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
570        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
571        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
572        BlockAuthorityV1::UserRejected => "rejected by the user",
573        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
574        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
575    }
576}
577
578/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
579/// for a `HostRequestObserved` `Denied` decision.
580fn host_request_denied_block(
581    method: &str,
582    by: ProviderHostDecisionAuthority,
583    reason: Option<&str>,
584) -> (String, BlockAuthorityV1, String) {
585    let authority = block_authority(by);
586    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
587    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
588    // rather than truncating -- this cut happens here, at the producer, before
589    // the validator ever sees the text.
590    let (reason_text, _) =
591        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
592    (tool_class_label(method), authority, reason_text)
593}
594
595// ---------------------------------------------------------------------------
596// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
597// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
598// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
599// prompt` RPC error whose vendor code names a quota/limit/rate
600// exhaustion). See
601// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
602// §3 slice K2.
603// ---------------------------------------------------------------------------
604
605/// Classifies ACP's `nonExecutionKind` into the typed authority a block
606/// this build mints under, or `None` when the tool truly did not run for a
607/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
608/// TURN stopped (session closed, the operator cancelled), not that a
609/// policy/classifier/rule refused the call, so those (and any unrecognized
610/// future value) stay a plain `ToolCompleted { success: false }` only, per
611/// this plan's own rule that a tool failure is never inflated into a block
612/// this build cannot actually attribute.
613fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
614    match kind? {
615        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
616        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
617        _ => None,
618    }
619}
620
621/// Splits a provider's own denial sentence into `reason`/`help`, when it
622/// matches the fixed shape Claude Code's classifier/permission-rule denial
623/// uses -- verified live, the owner's sample fixture
624/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
625/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
626/// `authority` is NEVER derived from this text (see this module's own rule
627/// that authority comes only from a typed field) -- only `reason`/`help`
628/// are. Falls back to the full text as `reason` with no `help` when the
629/// shape does not match (a provider whose text does not carry this exact
630/// wording) -- the honest minimum: the tool truly did not run, so SOME
631/// reason is always reported, never fabricated detail beyond what arrived.
632fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
633    let trimmed = text.trim();
634    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
635        return (trimmed.to_owned(), None);
636    };
637    let tail = after_reason.trim_start();
638    let Some((reason, help)) = tail.split_once(". ") else {
639        // "Reason:" present but no ". " to split help off -- the whole
640        // tail is the reason, e.g. "Reason: Blocked by classifier." with
641        // no help text following on some providers/versions.
642        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
643        return (reason.to_owned(), None);
644    };
645    let help = help.trim();
646    if help.is_empty() {
647        (reason.trim().to_owned(), None)
648    } else {
649        (reason.trim().to_owned(), Some(help.to_owned()))
650    }
651}
652
653/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
654/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
655/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
656/// name is never threaded through to its completion). Reads the class off
657/// the guidance tail's own "add a <Class> permission rule" sentence when
658/// present (the shape the owner's sample fixture uses) -- text matching
659/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
660/// this module's own rule). Falls back to the same generic `"Tool"` class
661/// `ToolCompleted`'s own observation already uses when nothing names one.
662fn tool_class_from_denial_help(help: Option<&str>) -> String {
663    help.and_then(|text| text.split("add a ").nth(1))
664        .and_then(|tail| tail.split(' ').next())
665        .filter(|word| !word.is_empty())
666        .map(str::to_owned)
667        .unwrap_or_else(|| "Tool".to_owned())
668}
669
670/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
671/// message) names a quota/rate-limit exhaustion -- Codex's
672/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
673/// contains "limit", the word this build actually keys on; "quota"/"rate"
674/// are kept for whichever OTHER vendor's own code names one of those
675/// instead, never confirmed live, but the honest generalization of the
676/// one confirmed sample.
677fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
678    fn names_quota(s: &str) -> bool {
679        let lower = s.to_ascii_lowercase();
680        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
681    }
682    vendor_code.is_some_and(names_quota) || names_quota(message)
683}
684
685/// The `Blocked` chunk this build mints when a turn stopped via a
686/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
687/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
688/// vendor code (or message) names a quota/limit/rate exhaustion
689/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
690/// `reason_kind` = the vendor code). `None` for every other stop --
691/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
692/// `Other`, or an RPC error whose vendor code/message names none of the
693/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
694/// those, never inflated into a block this build cannot actually
695/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
696/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
697/// honest minimum names the fact that happened, never invented detail; a
698/// later slice that threads the turn's last agent text chunk through can
699/// replace it without changing this function's contract.
700fn provider_session_end_block(
701    stop_reason: &ProviderStopReason,
702) -> Option<(BlockAuthorityV1, String, String)> {
703    match stop_reason {
704        ProviderStopReason::Refusal => Some((
705            BlockAuthorityV1::ProviderRefusal,
706            "refusal".to_owned(),
707            "the provider ended the turn with stopReason: refusal".to_owned(),
708        )),
709        ProviderStopReason::ProviderError { message, vendor_code, .. }
710            if provider_error_is_quota(vendor_code.as_deref(), message) =>
711        {
712            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
713            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
714        }
715        _ => None,
716    }
717}
718
719/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
720/// `agent-stream-events-v1` channel -- see
721/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
722/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
723/// event it derives from, so a client joining the two sees one id and one
724/// ordering:
725///
726/// - `source_sequence` on the chunk is the `source_sequence` the provider
727///   event itself carries.
728/// - `InteractionPrompt::correlation_id` is minted by
729///   `correlation::interaction_correlation` with the
730///   `(instance_id, generation, provider_sequence)` triple -- never the
731///   provider's own `request_id`, whose opacity on the wire is pinned by a
732///   dedicated test.
733///
734/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
735/// (nothing else carries stream content), for a source that is not a live
736/// session transport, and for the `ProviderEvent` variants below with no
737/// declared chunk kind to carry them, or whose declared chunk kind this build
738/// has no source for yet:
739///
740/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
741///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
742///   yet, the same gap the plan names explicitly.
743/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
744///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
745///   as "what the agent is saying", so mapping the operator's words onto
746///   `Text` would misrepresent whose content it is. No dedicated
747///   user-message kind is declared, so this stays unproduced rather than
748///   mislabeled.
749///
750/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
751/// decision returns `None` (nothing for the operator to react to), a `Denied`
752/// decision returns `Some(Blocked { .. })`.
753///
754/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
755/// below (K2): each returns `None` unless
756/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
757/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
758/// RPC error naming a quota).
759fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
760    let ControlEventKind::ProviderEvent {
761        sequence: provider_sequence,
762        source,
763        source_sequence,
764        event: provider_event,
765    } = &event.event
766    else {
767        return None;
768    };
769    if !provider_family_is_live(source.family) {
770        return None;
771    }
772    let kind = match provider_event {
773        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
774            text: text.clone(),
775            is_delta: *is_delta,
776        },
777        ProviderEvent::Thinking { text } => {
778            AgentStreamChunkKindV1::Thinking { text: text.clone() }
779        }
780        ProviderEvent::InteractionRequested {
781            interaction_kind,
782            tool_name,
783            title,
784            prompt,
785            options,
786            ..
787        } => AgentStreamChunkKindV1::InteractionPrompt {
788            correlation_id: interaction_correlation(
789                event.instance_id,
790                event.generation,
791                *provider_sequence,
792            ),
793            interaction_kind: *interaction_kind,
794            tool_name: tool_name.clone(),
795            // `title`/`options` are carried straight through from
796            // `ProviderEvent::InteractionRequested`, as the agent itself
797            // gave them on `session/request_permission`'s `PermissionTool
798            // Call.title`/`options` (see `gate4agent-shell-native`'s
799            // `provider_event`, the only ACP-transport producer). A PTY- or
800            // hook-sourced interaction carries `title: None` and
801            // `options: []` from its own producer because that source has
802            // none to offer, never because this mapping dropped one.
803            title: title.clone(),
804            prompt: prompt.clone(),
805            options: options
806                .iter()
807                .map(|option| AgentStreamInteractionOptionV1 {
808                    option_id: option.option_id.clone(),
809                    name: option.name.clone(),
810                    kind: option.kind.clone(),
811                })
812                .collect(),
813        },
814        ProviderEvent::ConfigOptionsUpdated { options } => {
815            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
816        }
817        // `available` is the mode catalogue `AcpSession::available_modes()`
818        // read back at `session/new` and threaded through by
819        // `gate4agent-shell-native`'s `provider_event` (see that crate's
820        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
821        // agent's own honest "no modes announced", never "not read": ACP
822        // orders `session/new` before any `session/update`, and the
823        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
824        // exist before the same session object's catalogue is already
825        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
826        // own doc comment for the ordering argument in full).
827        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
828            current: Some(mode_id.clone()),
829            available: available
830                .iter()
831                .map(|mode| AgentStreamNamedIdV1 {
832                    id: mode.id.clone(),
833                    name: mode.name.clone(),
834                    description: mode.description.clone(),
835                })
836                .collect(),
837        },
838        // A denied host request is a block -- see this function's own doc
839        // comment for why `HostRequestObserved` is not in the exclusion list
840        // below.
841        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
842            let ProviderHostRequestDecision::Denied { by } = decision else {
843                return None;
844            };
845            let (tool_class, authority, reason_text) =
846                host_request_denied_block(method, *by, reason.as_deref());
847            AgentStreamChunkKindV1::Blocked {
848                correlation_id: None,
849                tool_class,
850                authority,
851                reason_kind: None,
852                reason: reason_text,
853                help: None,
854            }
855        }
856        // Same relationship as `HostRequestObserved` above, driven by ACP's
857        // own `_meta.nonExecutionKind` instead of the host's
858        // `HostRequestDecision`.
859        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
860            let (authority, reason_kind) =
861                provider_tool_block_authority(non_execution_kind.as_deref())?;
862            let (reason, help) = split_provider_denial_text(output);
863            let (reason, _) =
864                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
865            let help = help.map(|help| {
866                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
867            });
868            AgentStreamChunkKindV1::Blocked {
869                correlation_id: Some(tool_correlation(event, source, id)),
870                tool_class: tool_class_from_denial_help(help.as_deref()),
871                authority,
872                reason_kind: Some(reason_kind.to_owned()),
873                reason,
874                help,
875            }
876        }
877        // Same relationship, driven by `stopReason: "refusal"` or a
878        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
879        // instead of a tool-level fact.
880        ProviderEvent::SessionEnded { stop_reason, .. } => {
881            let (authority, reason_kind, reason) =
882                stop_reason.as_ref().and_then(provider_session_end_block)?;
883            let (reason, _) =
884                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
885            AgentStreamChunkKindV1::Blocked {
886                correlation_id: None,
887                tool_class: "Session".to_owned(),
888                authority,
889                reason_kind: Some(reason_kind),
890                reason,
891                help: None,
892            }
893        }
894        ProviderEvent::SessionStarted { .. }
895        | ProviderEvent::SessionIdentityObserved { .. }
896        | ProviderEvent::TurnStarted { .. }
897        | ProviderEvent::WorkingObserved
898        | ProviderEvent::ToolStarted { .. }
899        | ProviderEvent::TurnCompleted { .. }
900        | ProviderEvent::ContextWindowUsage { .. }
901        | ProviderEvent::TurnInterrupted
902        | ProviderEvent::Error { .. }
903        | ProviderEvent::Ready
904        | ProviderEvent::InteractionResolved { .. }
905        | ProviderEvent::SubagentStarted { .. }
906        | ProviderEvent::SubagentStopped { .. }
907        | ProviderEvent::RateLimited { .. }
908        | ProviderEvent::UnrecognizedNotification { .. }
909        | ProviderEvent::UserMessage { .. }
910        | ProviderEvent::Plan { .. }
911        | ProviderEvent::AvailableCommandsUpdated { .. }
912        | ProviderEvent::SessionInfoUpdated { .. }
913        | ProviderEvent::UsageUpdated { .. } => return None,
914    };
915    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
916}
917
918async fn run_native_session_catalog_operation<T, E, F>(
919    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
920    operation: &'static str,
921    execute: F,
922) -> Result<T, NodeFailure>
923where
924    T: Send + 'static,
925    E: std::fmt::Display + Send + 'static,
926    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
927{
928    let task = tokio::task::spawn_blocking(move || {
929        let mut catalog = match catalog.try_lock() {
930            Ok(catalog) => catalog,
931            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
932            Err(std::sync::TryLockError::WouldBlock) => {
933                return Err(failure(
934                    NodeFailureCode::BackendBusy,
935                    "native session catalog operation is already running",
936                ));
937            }
938        };
939        execute(&mut catalog).map_err(|error| {
940            failure(
941                NodeFailureCode::BackendOperationFailed,
942                &format!("{operation} failed: {error}"),
943            )
944        })
945    });
946    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
947        .await
948        .map_err(|_| {
949            failure(
950                NodeFailureCode::BackendBusy,
951                &format!("{operation} exceeded its bounded deadline"),
952            )
953        })?
954        .map_err(|_| {
955            failure(
956                NodeFailureCode::BackendOperationFailed,
957                &format!("{operation} task failed"),
958            )
959        })?
960}
961
962fn windows_path_text(path: &OpaqueHostPath) -> &str {
963    path.as_utf8()
964        .expect("Windows node only creates UTF-8 opaque host paths")
965}
966
967fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
968    path.as_utf8()
969        .map(str::to_owned)
970        .ok_or_else(|| failure(
971            NodeFailureCode::InvalidRequest,
972            "this node requires UTF-8 host paths",
973        ))
974}
975
976fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
977    GitWorktreeSnapshot {
978        path: opaque_windows_path(worktree.path),
979        head: worktree.head,
980        branch: worktree.branch,
981        is_bare: worktree.is_bare,
982        is_main: worktree.is_main,
983        locked: worktree.locked,
984        lock_reason: worktree.lock_reason,
985        prunable: worktree.prunable,
986        prunable_reason: worktree.prunable_reason,
987        workspace_id: worktree.workspace_id.take(),
988    }
989}
990
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct WorkspaceConfig {
993    workspace_id: WorkspaceId,
994    canonical_root: String,
995    worktree_service_mode: WorktreeServiceMode,
996    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
997}
998
999impl WorkspaceConfig {
1000    pub fn new(
1001        workspace_id: WorkspaceId,
1002        root: impl AsRef<Path>,
1003    ) -> Result<Self, NodeServerError> {
1004        let root = root.as_ref();
1005        if !root.is_absolute() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not absolute".to_owned(),
1010            });
1011        }
1012        let canonical = std::fs::canonicalize(root).map_err(|error| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: root.to_string_lossy().into_owned(),
1016                message: error.to_string(),
1017            }
1018        })?;
1019        if !canonical.is_dir() {
1020            return Err(NodeServerError::InvalidWorkspaceRoot {
1021                workspace_id,
1022                path: root.to_string_lossy().into_owned(),
1023                message: "path is not a directory".to_owned(),
1024            });
1025        }
1026        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1027            NodeServerError::InvalidWorkspaceRoot {
1028                workspace_id: workspace_id.clone(),
1029                path: path.to_string_lossy().into_owned(),
1030                message: "canonical path is not valid Unicode".to_owned(),
1031            }
1032        })?;
1033        let canonical_root = platform::normalize_canonical_root(canonical_root);
1034        validate_workspace_root(&workspace_id, &canonical_root)?;
1035        Ok(Self {
1036            workspace_id,
1037            canonical_root,
1038            worktree_service_mode: WorktreeServiceMode::Manual,
1039            managed_worktree_profiles: BTreeMap::new(),
1040        })
1041    }
1042
1043    pub fn workspace_id(&self) -> &WorkspaceId {
1044        &self.workspace_id
1045    }
1046
1047    pub fn canonical_root(&self) -> &str {
1048        &self.canonical_root
1049    }
1050
1051    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1052        self.worktree_service_mode = mode;
1053        self
1054    }
1055
1056    pub fn with_managed_worktree_profile(
1057        mut self,
1058        profile: ManagedWorktreeProfile,
1059    ) -> Result<Self, NodeServerError> {
1060        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1061            NodeServerError::InvalidManagedWorktreeProfile {
1062                workspace_id: self.workspace_id.clone(),
1063                profile_id: profile.profile_id().clone(),
1064                message,
1065            }
1066        })?;
1067        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1068            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1069                workspace_id: self.workspace_id,
1070                profile_id: profile.profile_id().clone(),
1071            });
1072        }
1073        if self.managed_worktree_profiles.len()
1074            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1075        {
1076            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1077                workspace_id: self.workspace_id,
1078                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1079            });
1080        }
1081        self.managed_worktree_profiles
1082            .insert(profile.profile_id().clone(), profile);
1083        Ok(self)
1084    }
1085}
1086
1087#[derive(Clone)]
1088pub struct NodeServerConfig {
1089    pub endpoint: String,
1090    api_listen: Option<std::net::SocketAddr>,
1091    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1092    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1093    bridge_listen: Option<std::net::SocketAddr>,
1094    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1095    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1096    bridge_token: Option<String>,
1097    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1098    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1099    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1100    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1101    /// node purely a listener, which is what it has always been.
1102    call_home: Option<std::net::SocketAddr>,
1103    /// When set, call-home authenticates by signing the relay's challenge with this
1104    /// identity (enrolled node) and falls back to the token handshake against a relay
1105    /// that sends no challenge.
1106    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1107    pub node_id: NodeId,
1108    pub workspaces: Vec<WorkspaceConfig>,
1109    access_token: String,
1110    pub runtime: NativeRuntimeConfig,
1111    session_record_retention: SessionRecordRetentionConfig,
1112    state_path: Option<PathBuf>,
1113    spawn_profiles: SpawnProfileRegistry,
1114    session_environment: Option<NodeSessionEnvironmentConfig>,
1115    history: Option<NativeHistoryConfig>,
1116    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1117    /// Station network allowlist catalog (opaque ids). Empty default.
1118    /// Loaded at startup from `--network-allowlist-catalog` / env
1119    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1120    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1121    #[cfg(feature = "fixture")]
1122    fixture_raw_pty_runtime: bool,
1123}
1124
1125#[derive(Clone)]
1126struct NodeSessionEnvironmentConfig {
1127    root: PathBuf,
1128    resolver: Arc<dyn NodeSecretResolver>,
1129}
1130
1131impl NodeServerConfig {
1132    pub fn new(
1133        endpoint: impl Into<String>,
1134        access_token: impl Into<String>,
1135        node_id: NodeId,
1136        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1137    ) -> Result<Self, NodeServerError> {
1138        let endpoint = endpoint.into();
1139        let access_token = access_token.into();
1140        if !platform::validate_endpoint(&endpoint) {
1141            return Err(NodeServerError::InvalidEndpoint);
1142        }
1143        if access_token.is_empty() || access_token.len() > 4_096 {
1144            return Err(NodeServerError::InvalidAccessToken);
1145        }
1146        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1147        if workspaces.is_empty() {
1148            return Err(NodeServerError::NoWorkspaces);
1149        }
1150        let mut workspace_ids = BTreeMap::new();
1151        let mut workspace_roots = BTreeMap::new();
1152        for workspace in &workspaces {
1153            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1154                && workspace.managed_worktree_profiles.is_empty()
1155            {
1156                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1157                    workspace.workspace_id.clone(),
1158                ));
1159            }
1160            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1161                && !workspace.managed_worktree_profiles.is_empty()
1162            {
1163                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1164                    workspace.workspace_id.clone(),
1165                ));
1166            }
1167            if workspace_ids
1168                .insert(workspace.workspace_id.clone(), ())
1169                .is_some()
1170            {
1171                return Err(NodeServerError::DuplicateWorkspaceId(
1172                    workspace.workspace_id.clone(),
1173                ));
1174            }
1175            let root_key = platform::root_identity(&workspace.canonical_root);
1176            if let Some(existing) = workspace_roots.insert(
1177                root_key,
1178                workspace.workspace_id.clone(),
1179            ) {
1180                return Err(NodeServerError::DuplicateWorkspaceRoot {
1181                    first: existing,
1182                    second: workspace.workspace_id.clone(),
1183                    root: workspace.canonical_root.clone(),
1184                });
1185            }
1186        }
1187        Ok(Self {
1188            endpoint,
1189            api_listen: None,
1190            bridge_listen: None,
1191            bridge_token: None,
1192            bridge_underlay: None,
1193            call_home: None,
1194            call_home_identity: None,
1195            node_id,
1196            workspaces,
1197            access_token,
1198            // PTY output is polled by the runtime's effect worker; the upstream
1199            // default (20ms) added ~10-20ms to every keystroke echo.
1200            runtime: NativeRuntimeConfig {
1201                worker_poll_interval_ms: 4,
1202                ..NativeRuntimeConfig::default()
1203            },
1204            session_record_retention: SessionRecordRetentionConfig::default(),
1205            state_path: None,
1206            spawn_profiles: SpawnProfileRegistry::default(),
1207            session_environment: None,
1208            history: None,
1209            harness_mcp_helper: None,
1210            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1211            #[cfg(feature = "fixture")]
1212            fixture_raw_pty_runtime: false,
1213        })
1214    }
1215
1216    pub fn with_api_listen(
1217        mut self,
1218        api_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !api_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidApiListen(api_listen));
1222        }
1223        self.api_listen = Some(api_listen);
1224        Ok(self)
1225    }
1226
1227    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1228    /// as `with_api_listen`). Does not enable by default — libraries and the
1229    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1230    pub fn with_bridge_listen(
1231        mut self,
1232        bridge_listen: std::net::SocketAddr,
1233    ) -> Result<Self, NodeServerError> {
1234        if !bridge_listen.ip().is_loopback() {
1235            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1236        }
1237        self.bridge_listen = Some(bridge_listen);
1238        Ok(self)
1239    }
1240
1241    /// Optional bridge shared secret, distinct from the C2/node access token.
1242    /// Never log the value. Empty / oversize refused.
1243    pub fn with_bridge_token(
1244        mut self,
1245        bridge_token: impl Into<String>,
1246    ) -> Result<Self, NodeServerError> {
1247        let bridge_token = bridge_token.into();
1248        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1249            return Err(NodeServerError::InvalidBridgeToken);
1250        }
1251        self.bridge_token = Some(bridge_token);
1252        Ok(self)
1253    }
1254
1255    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1256    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1257    /// already configured. Transport key is 32 raw bytes (never logged).
1258    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1259    pub fn with_bridge_underlay(
1260        mut self,
1261        bind: std::net::SocketAddr,
1262        transport_key: [u8; 32],
1263        underlay_token: impl Into<String>,
1264    ) -> Result<Self, NodeServerError> {
1265        if self.bridge_listen.is_none() {
1266            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1267        }
1268        if self.bridge_token.is_none() {
1269            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1270        }
1271        let underlay_token = underlay_token.into();
1272        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1273            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1274        }
1275        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1276        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1277            bind,
1278            transport_key,
1279            underlay_token,
1280        });
1281        Ok(self)
1282    }
1283
1284    /// Also dial `relay` and serve this wire over the connection, in
1285    /// addition to accepting on the local endpoint.
1286    ///
1287    /// Who opens the socket and who is the protocol's server are separate
1288    /// questions, and this is the node's half of separating them. The node
1289    /// stays the server either way -- it answers `NodeRequest`s and emits
1290    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1291    /// that changes is that a node with no address anyone can reach makes
1292    /// the connection itself, and names itself once it arrives (see
1293    /// `gate4agent_node_wire::write_call_home_announce`).
1294    ///
1295    /// Loopback only, and that is not an oversight to be flagged and
1296    /// worked around. This wire carries terminal contents, keystrokes and
1297    /// file bytes as plaintext JSON; its mutual challenge-response
1298    /// authenticates both ends and encrypts nothing. Every listener in
1299    /// this stack is loopback-guarded for that reason, and a dialler must
1300    /// hold the same line -- a node calling a public relay would put the
1301    /// whole control plane on the wire in the clear. Reaching a relay on
1302    /// another host is a transport question, and it stays unanswered here
1303    /// on purpose: this change is about direction, not distance.
1304    pub fn with_call_home(
1305        mut self,
1306        relay: std::net::SocketAddr,
1307    ) -> Result<Self, NodeServerError> {
1308        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1309        // and port 0 refused — plaintext wire must not dial every interface.
1310        if relay.port() == 0 || relay.ip().is_unspecified() {
1311            return Err(NodeServerError::InvalidCallHome(relay));
1312        }
1313        self.call_home = Some(relay);
1314        Ok(self)
1315    }
1316
1317    /// Authenticate call-home with a node-held identity instead of a standing token.
1318    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1319        self.call_home_identity = Some(identity);
1320        self
1321    }
1322
1323    pub fn with_state_path(
1324        mut self,
1325        state_path: impl Into<PathBuf>,
1326    ) -> Result<Self, NodeServerError> {
1327        let state_path = state_path.into();
1328        if !state_path.is_absolute() || state_path.file_name().is_none() {
1329            return Err(NodeServerError::InvalidStatePath(
1330                state_path.to_string_lossy().into_owned(),
1331            ));
1332        }
1333        self.state_path = Some(state_path);
1334        Ok(self)
1335    }
1336
1337    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1338        self.spawn_profiles = spawn_profiles;
1339        self
1340    }
1341
1342    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1343    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1344    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1345    /// when this is never called -- a node never deletes a durable record
1346    /// until an operator opts in with a real value.
1347    pub fn with_session_record_retention(
1348        mut self,
1349        retention: SessionRecordRetentionConfig,
1350    ) -> Self {
1351        self.session_record_retention = retention;
1352        self
1353    }
1354
1355    pub fn with_session_environment_materialization(
1356        mut self,
1357        root: impl Into<PathBuf>,
1358        resolver: Arc<dyn NodeSecretResolver>,
1359    ) -> Result<Self, NodeServerError> {
1360        let root = root.into();
1361        if !root.is_absolute() {
1362            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1363        }
1364        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1365        Ok(self)
1366    }
1367
1368    /// Enables bounded provider-history discovery and loading from explicit
1369    /// host-owned roots. Ambient vendor homes are never inferred here.
1370    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1371        self.history = Some(history);
1372        self
1373    }
1374
1375    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1376    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1377    /// Both platforms review the helper the same way (absolute regular file +
1378    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1379    /// gate after Unix listener/peer-cred support landed.
1380    pub fn with_harness_mcp_helper(
1381        mut self,
1382        helper_program: impl Into<PathBuf>,
1383    ) -> Result<Self, NodeServerError> {
1384        self.harness_mcp_helper = Some(
1385            ReviewedHarnessMcpProgram::review(helper_program.into())
1386                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1387        );
1388        Ok(self)
1389    }
1390
1391    /// Install an in-memory station network allowlist catalog (opaque ids +
1392    /// optional node-local permits / provider-native mapping). Empty remains
1393    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1394    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1395    pub fn with_network_allowlist_catalog(
1396        mut self,
1397        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1398    ) -> Result<Self, NodeServerError> {
1399        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1400            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1401                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1402            });
1403        }
1404        self.network_allowlist_catalog = catalog;
1405        Ok(self)
1406    }
1407
1408    /// Load station network allowlist catalog from an absolute regular file
1409    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1410    /// that want env fallback use `resolve_network_allowlist_catalog`.
1411    pub fn with_network_allowlist_catalog_file(
1412        self,
1413        path: impl AsRef<std::path::Path>,
1414    ) -> Result<Self, NodeServerError> {
1415        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1416            .map_err(NodeServerError::from)?;
1417        self.with_network_allowlist_catalog(catalog)
1418    }
1419}
1420
1421pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1422    platform::default_state_path(node_id.as_str())
1423        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1424}
1425
1426pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1427    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1428}
1429
1430fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1431    let parent = state_path.parent()?;
1432    let mut name = state_path.file_name()?.to_os_string();
1433    name.push(".delivery-store");
1434    Some(parent.join(name))
1435}
1436
1437fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1438    let parent = state_path.parent()?;
1439    let mut name = state_path.file_name()?.to_os_string();
1440    name.push(".context-pack-store");
1441    Some(parent.join(name))
1442}
1443
1444pub struct NodeServer {
1445    config: NodeServerConfig,
1446    runtime: NativeRuntime,
1447    shared: Arc<NodeShared>,
1448    events: EventSubscription,
1449    state_path_lock: Option<session_registry::StatePathLock>,
1450}
1451
1452#[cfg(feature = "fixture")]
1453#[derive(Clone, Default)]
1454pub struct SpawnManagedWorktreeV2FailureProbe {
1455    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1456}
1457
1458#[cfg(feature = "fixture")]
1459impl SpawnManagedWorktreeV2FailureProbe {
1460    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1461        *self
1462            .latest_code
1463            .lock()
1464            .unwrap_or_else(|poisoned| poisoned.into_inner())
1465    }
1466
1467    fn record(&self, code: Option<NodeFailureCode>) {
1468        *self
1469            .latest_code
1470            .lock()
1471            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1472    }
1473}
1474
1475#[cfg(all(feature = "fixture", windows))]
1476struct IsolatedCodexHomeFixtureEnvironment;
1477
1478#[cfg(all(feature = "fixture", windows))]
1479impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1480    for IsolatedCodexHomeFixtureEnvironment
1481{
1482    fn resolve_child_environment(
1483        &self,
1484    ) -> Result<
1485        Vec<gate4agent_catalog::EnvMutation>,
1486        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1487    > {
1488        Ok(vec![gate4agent_catalog::EnvMutation {
1489            key: OsString::from("USERPROFILE"),
1490            value: None,
1491        }])
1492    }
1493}
1494
1495impl NodeServer {
1496    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1497    /// must present.
1498    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1499    #[cfg(unix)]
1500    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1501        self.shared.signer_proxy.set(proxy).is_ok()
1502    }
1503
1504    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1505        self.shared.m4a_accounts.as_ref()?;
1506        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1507    }
1508
1509    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1510        let catalog = active_registry()?;
1511        Self::new_with_registry(config, catalog)
1512    }
1513
1514    #[cfg(feature = "fixture")]
1515    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1516        let mut spec = gate4agent_testkit::interactive_agent_spec();
1517        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1518        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1519        Self::new_fixture_with_spec(config, spec)
1520    }
1521
1522    #[cfg(feature = "fixture")]
1523    pub fn new_clean_exit_fixture(
1524        config: NodeServerConfig,
1525        fixture_root: PathBuf,
1526        started_marker: PathBuf,
1527        release_signal: PathBuf,
1528    ) -> Result<Self, NodeServerError> {
1529        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1530            &fixture_root,
1531            &started_marker,
1532            &release_signal,
1533        )
1534        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535        spec.id = AgentId::new("claude")
1536            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1538        Self::new_fixture_with_spec(config, spec)
1539    }
1540
1541    /// A `claude` source whose session establishes a verified provider
1542    /// identity via an early `SessionStart` Hook post (so it is eligible for
1543    /// auto-export-at-exit and its native history is independently
1544    /// loadable), waits for `release_signal`, then exits cleanly — paired
1545    /// on the same Node with a plain `codex` target that never validates
1546    /// anything about what it receives. Built for A2 durable ContextPack
1547    /// E2E coverage: the test itself inspects materialized files and
1548    /// managed-session-record fields directly, rather than having the
1549    /// target script self-validate.
1550    #[cfg(all(feature = "fixture", windows))]
1551    pub fn new_durable_context_pack_clean_exit_fixture(
1552        config: NodeServerConfig,
1553        fixture_root: PathBuf,
1554        started_marker: PathBuf,
1555        release_signal: PathBuf,
1556        session_id: &str,
1557    ) -> Result<Self, NodeServerError> {
1558        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1559            &fixture_root,
1560            &started_marker,
1561            &release_signal,
1562            session_id,
1563        )
1564        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1565        let source_id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1568            NodeServerError::Registry(
1569                "durable context pack clean-exit fixture source adapter is unavailable"
1570                    .to_owned(),
1571            )
1572        })?;
1573        source_spec.capabilities.adapters.history =
1574            source_provider.capabilities.adapters.history.clone();
1575        source_spec.id = source_id;
1576        source_spec.display_name =
1577            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1578        // Both specs launch via `powershell.exe`; the registry's ambiguity
1579        // check keys on `detection.command` (+ aliases), not `launch.program`
1580        // -- give each a distinct detection identity, same precedent as
1581        // `context_pack_fixture_catalog`'s per-provider
1582        // `gate4agent-{provider}-context-fixture.cmd` commands.
1583        source_spec.detection.command =
1584            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1585        source_spec.detection.aliases.clear();
1586
1587        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1588        target_spec.id = AgentId::new("codex")
1589            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1590        target_spec.display_name =
1591            "Controlled Codex durable context-pack target fixture".to_owned();
1592        target_spec.detection.command =
1593            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1594        target_spec.detection.aliases.clear();
1595
1596        let catalog = AgentRegistry::new([source_spec, target_spec])
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_with_registry(config, catalog)?;
1599        Arc::get_mut(&mut server.shared)
1600            .expect("fixture Node shared state must remain uniquely owned before run")
1601            .fixture_semantic_hook_policy = true;
1602        Ok(server)
1603    }
1604
1605    #[cfg(feature = "fixture")]
1606    pub fn new_monitoring_hook_fixture(
1607        config: NodeServerConfig,
1608    ) -> Result<Self, NodeServerError> {
1609        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1610        spec.id = AgentId::new("claude")
1611            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1612        let mut server = Self::new_fixture_with_spec(config, spec)?;
1613        Arc::get_mut(&mut server.shared)
1614            .expect("fixture Node shared state must remain uniquely owned before run")
1615            .fixture_semantic_hook_policy = true;
1616        Ok(server)
1617    }
1618
1619    #[cfg(all(feature = "fixture", windows))]
1620    pub fn new_provider_bundle_argv_fixture(
1621        config: NodeServerConfig,
1622        agent_id: AgentId,
1623        proof_path: PathBuf,
1624    ) -> Result<Self, NodeServerError> {
1625        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1626        Self::new_fixture_with_spec(config, spec)
1627    }
1628
1629    #[cfg(all(feature = "fixture", windows))]
1630    pub fn new_provider_bundle_argv_hold_fixture(
1631        config: NodeServerConfig,
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: PathBuf,
1635    ) -> Result<Self, NodeServerError> {
1636        let spec = Self::provider_bundle_argv_fixture_spec(
1637            agent_id,
1638            proof_path,
1639            Some(release_signal),
1640        )?;
1641        Self::new_fixture_with_spec(config, spec)
1642    }
1643
1644    #[cfg(all(feature = "fixture", windows))]
1645    fn provider_bundle_argv_fixture_spec(
1646        agent_id: AgentId,
1647        proof_path: PathBuf,
1648        release_signal: Option<PathBuf>,
1649    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1650        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1651            return Err(NodeServerError::Registry(
1652                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1653            ));
1654        }
1655        let mut spec = gate4agent_testkit::interactive_agent_spec();
1656        spec.id = agent_id.clone();
1657        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1658        let provider = builtin_registry()
1659            .get(&agent_id)
1660            .ok_or_else(|| {
1661                NodeServerError::Registry(
1662                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1663                )
1664            })?;
1665        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1666        if !proof_path.is_absolute()
1667            || !proof_path
1668                .parent()
1669                .is_some_and(|parent| parent.is_dir())
1670        {
1671            return Err(NodeServerError::Registry(
1672                "provider bundle argv proof path requires an existing absolute parent"
1673                    .to_owned(),
1674            ));
1675        }
1676        if let Some(release_signal) = release_signal.as_ref() {
1677            if !release_signal.is_absolute()
1678                || !release_signal
1679                    .parent()
1680                    .is_some_and(|parent| parent.is_dir())
1681            {
1682                return Err(NodeServerError::Registry(
1683                    "provider bundle argv release path requires an existing absolute parent"
1684                        .to_owned(),
1685                ));
1686            }
1687            if release_signal.exists() {
1688                return Err(NodeServerError::Registry(
1689                    "provider bundle argv release path must not exist".to_owned(),
1690                ));
1691            }
1692            let proof_parent = std::fs::canonicalize(
1693                proof_path
1694                    .parent()
1695                    .expect("validated provider bundle proof parent"),
1696            )
1697            .map_err(|_| {
1698                NodeServerError::Registry(
1699                    "provider bundle argv proof parent is unavailable".to_owned(),
1700                )
1701            })?;
1702            let release_parent = std::fs::canonicalize(
1703                release_signal
1704                    .parent()
1705                    .expect("validated provider bundle release parent"),
1706            )
1707            .map_err(|_| {
1708                NodeServerError::Registry(
1709                    "provider bundle argv release parent is unavailable".to_owned(),
1710                )
1711            })?;
1712            if proof_parent != release_parent {
1713                return Err(NodeServerError::Registry(
1714                    "provider bundle argv release path must share the proof parent".to_owned(),
1715                ));
1716            }
1717        }
1718        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1719            NodeServerError::Registry(
1720                "provider bundle argv proof path is not valid Unicode".to_owned(),
1721            )
1722        })?;
1723        let release_signal = release_signal.map(|release_signal| {
1724            release_signal.into_os_string().into_string().map_err(|_| {
1725                NodeServerError::Registry(
1726                    "provider bundle argv release path is not valid Unicode".to_owned(),
1727                )
1728            })
1729        }).transpose()?;
1730        let script = spec
1731            .launch
1732            .fixed_args
1733            .pop()
1734            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1735        let bundle_validation = if agent_id.as_str() == "codex" {
1736            "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') }"
1737        } else if agent_id.as_str() == "claude" {
1738            "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 }"
1739        } else {
1740            "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 }"
1741        };
1742        if let Some(release_signal) = release_signal {
1743            spec.launch.fixed_args.push(format!(
1744                "& {{ 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} }}",
1745            ));
1746            spec.launch.fixed_args.push(proof_path);
1747            spec.launch.fixed_args.push(release_signal);
1748        } else {
1749            spec.launch.fixed_args.push(format!(
1750                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1751            ));
1752            spec.launch.fixed_args.push(proof_path);
1753        }
1754        Ok(spec)
1755    }
1756
1757    #[cfg(all(feature = "fixture", windows))]
1758    pub fn new_context_pack_fixture(
1759        config: NodeServerConfig,
1760        proof_path: PathBuf,
1761    ) -> Result<Self, NodeServerError> {
1762        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1763        Self::new_with_registry(config, catalog)
1764    }
1765
1766    #[cfg(all(feature = "fixture", windows))]
1767    pub fn new_monitoring_context_pack_fixture(
1768        config: NodeServerConfig,
1769        proof_path: PathBuf,
1770    ) -> Result<Self, NodeServerError> {
1771        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1772        let mut server = Self::new_with_registry(config, catalog)?;
1773        Arc::get_mut(&mut server.shared)
1774            .expect("fixture Node shared state must remain uniquely owned before run")
1775            .fixture_semantic_hook_policy = true;
1776        Ok(server)
1777    }
1778
1779    #[cfg(all(feature = "fixture", windows))]
1780    pub fn install_isolated_codex_home_environment_fixture(
1781        &mut self,
1782        profile_id: SpawnEnvironmentProfileId,
1783        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1784    ) -> Result<(), NodeServerError> {
1785        let provider = AgentId::new("codex")
1786            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1788            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1789                "{}-pty",
1790                profile_id.as_str(),
1791            ))
1792            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1793            provider.clone(),
1794            TransportKind::Pty,
1795            vec![OsString::from("USERPROFILE")],
1796            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1797        )
1798        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1799        let materialization = NodeSessionMaterializationProfile::new(
1800            Vec::new(),
1801            vec![crate::session_environment::NodeSessionPathBinding::new(
1802                "CODEX_HOME",
1803                crate::session_environment::NodeSessionPathClass::ProviderHome,
1804            )
1805            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1806            Vec::new(),
1807        )
1808        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1809        self.install_environment_profile(
1810            NodeEnvironmentProfile::new_with_materialization(
1811                profile_id,
1812                profile_revision,
1813                provider,
1814                [native_profile],
1815                Some(materialization),
1816            )
1817            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1818        )
1819    }
1820
1821    #[cfg(all(feature = "fixture", windows))]
1822    pub fn new_context_only_proof_fixture(
1823        config: NodeServerConfig,
1824        target_provider: AgentId,
1825        proof_path: PathBuf,
1826    ) -> Result<Self, NodeServerError> {
1827        if target_provider.as_str() != "kimi" {
1828            return Err(NodeServerError::Registry(
1829                "context-only proof fixture requires Kimi".to_owned(),
1830            ));
1831        }
1832        let catalog = Self::context_pack_fixture_catalog(
1833            proof_path,
1834            Some(&target_provider),
1835            false,
1836        )?;
1837        Self::new_with_registry(config, catalog)
1838    }
1839
1840    #[cfg(all(feature = "fixture", windows))]
1841    fn context_pack_fixture_catalog(
1842        proof_path: PathBuf,
1843        context_only_target: Option<&AgentId>,
1844        monitoring_claude_source: bool,
1845    ) -> Result<AgentRegistry, NodeServerError> {
1846        if !proof_path.is_absolute()
1847            || !proof_path
1848                .parent()
1849                .is_some_and(|parent| parent.is_dir())
1850        {
1851            return Err(NodeServerError::Registry(
1852                "context pack proof path requires an existing absolute parent".to_owned(),
1853            ));
1854        }
1855        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1856            NodeServerError::Registry(
1857                "context pack proof path is not valid Unicode".to_owned(),
1858            )
1859        })?;
1860        let providers = ["claude", "codex", "grok", "kimi"];
1861        let builtins = builtin_registry();
1862        let mut specs = Vec::with_capacity(providers.len());
1863        for provider_id in providers {
1864            let agent_id = AgentId::new(provider_id)
1865                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1866            let provider = builtins.get(&agent_id).ok_or_else(|| {
1867                NodeServerError::Registry(
1868                    "context pack fixture provider adapter is unavailable".to_owned(),
1869                )
1870            })?;
1871            let monitored_provider =
1872                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1873            let mut spec = if monitored_provider {
1874                gate4agent_testkit::monitoring_hook_agent_spec()
1875            } else {
1876                gate4agent_testkit::interactive_agent_spec()
1877            };
1878            spec.id = agent_id.clone();
1879            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1880            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1881            spec.detection.aliases.clear();
1882            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1883            if provider_id == "codex" {
1884                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1885                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1886                })?;
1887                let validation = r#"
1888$bundleArgs = @($bundleArgs)
1889if ($bundleArgs.Count -ne 0) { exit 91 }
1890$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1891$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1892$cwd = (Get-Location).ProviderPath
1893if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1894$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1895if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1896$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1897if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1901if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1902$contextPath = Join-Path $contextRoot 'context-pack.json'
1903if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1904$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1905if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1906$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1907$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1908$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$separator = [IO.Path]::DirectorySeparatorChar
1910if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1912$rawContext = [IO.File]::ReadAllText($contextPath)
1913if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1914try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1915$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1916$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1917foreach ($property in @($document.PSObject.Properties.Name)) {
1918    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1919}
1920foreach ($property in $requiredTopLevel) {
1921    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1922}
1923if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1924if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1925$messages = @($document.retained_messages)
1926if ($messages.Count -eq 0) { exit 107 }
1927foreach ($message in $messages) {
1928    $messageProperties = @($message.PSObject.Properties.Name)
1929    foreach ($property in $messageProperties) {
1930        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1931    }
1932    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1933}
1934$sha = [Security.Cryptography.SHA256]::Create()
1935try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1936[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))
1937$rawProof = [IO.File]::ReadAllText($proofPath)
1938if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1939[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1940"#;
1941                spec.launch.fixed_args.push(format!(
1942                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1943                ));
1944                spec.launch.fixed_args.push(proof_path.clone());
1945            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1946                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1947                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1948                })?;
1949                let validation = r#"
1950$bundleArgs = @($bundleArgs)
1951if ($bundleArgs.Count -ne 0) { exit 91 }
1952$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1953$cwd = (Get-Location).ProviderPath
1954if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1955$contextPath = Join-Path $contextRoot 'context-pack.json'
1956if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1957$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1958if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1959$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1960$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1961$separator = [IO.Path]::DirectorySeparatorChar
1962if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1963try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1964if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1965if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1966$messages = @($document.retained_messages)
1967if ($messages.Count -eq 0) { exit 99 }
1968$hasUser = $false
1969$hasAssistant = $false
1970foreach ($message in $messages) {
1971    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1972    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1973    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1974    else { exit 101 }
1975}
1976if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1977$sha = [Security.Cryptography.SHA256]::Create()
1978try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1979[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1980[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1981"#;
1982                spec.launch.fixed_args.push(format!(
1983                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1984                ));
1985                spec.launch.fixed_args.push(proof_path.clone());
1986            }
1987            specs.push(spec);
1988        }
1989        let catalog = AgentRegistry::new(specs)
1990            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1991        Ok(catalog)
1992    }
1993
1994    #[cfg(feature = "fixture")]
1995    pub fn new_exact_launcher_fixture(
1996        config: NodeServerConfig,
1997        agent_id: AgentId,
1998        launcher: String,
1999    ) -> Result<Self, NodeServerError> {
2000        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2001            return Err(NodeServerError::Registry(
2002                "exact launcher fixture requires a supported provider".to_owned(),
2003            ));
2004        }
2005        if launcher.contains('\0') {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path contains NUL".to_owned(),
2008            ));
2009        }
2010        let launcher_path = Path::new(&launcher);
2011        if !launcher_path.is_absolute() {
2012            return Err(NodeServerError::Registry(
2013                "exact launcher fixture path must be absolute".to_owned(),
2014            ));
2015        }
2016        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2017            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2018        })?;
2019        if !metadata.is_file() {
2020            return Err(NodeServerError::Registry(
2021                "exact launcher fixture path must be a regular file".to_owned(),
2022            ));
2023        }
2024        let mut spec = builtin_registry()
2025            .get(&agent_id)
2026            .ok_or_else(|| {
2027                NodeServerError::Registry(
2028                    "exact launcher fixture provider is unavailable".to_owned(),
2029                )
2030            })?
2031            .clone();
2032        spec.launch.program = launcher;
2033        Self::new_fixture_with_spec(config, spec)
2034    }
2035
2036    /// Shared H3B validation and spec construction for
2037    /// `new_harness_mcp_proxy_fixture` and
2038    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2039    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2040    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2041    /// the named builtin catalog entry -- detection command and expected
2042    /// process matcher rebased on the launcher's own basename, exactly the
2043    /// same shape `new_harness_mcp_proxy_fixture` always built.
2044    #[cfg(feature = "fixture")]
2045    fn h3b_provider_spec_with_program_and_args(
2046        agent_id: AgentId,
2047        provider_program: PathBuf,
2048        fixed_args: Vec<OsString>,
2049    ) -> Result<AgentSpec, NodeServerError> {
2050        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2051            return Err(NodeServerError::Registry(
2052                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2053            ));
2054        }
2055        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2056            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2057        })?;
2058        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2059            return Err(NodeServerError::Registry(
2060                "H3B fixture provider must be an exact regular file".to_owned(),
2061            ));
2062        }
2063        let program = provider_program.into_os_string().into_string().map_err(|_| {
2064            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2065        })?;
2066        let fixed_args = fixed_args.into_iter().map(|argument| {
2067            argument.into_string().map_err(|_| NodeServerError::Registry(
2068                "H3B fixture argument is not Unicode".to_owned(),
2069            ))
2070        }).collect::<Result<Vec<_>, _>>()?;
2071        let argument_bytes = fixed_args.iter()
2072            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2073        if program.contains('\0')
2074            || fixed_args.iter().any(|argument| argument.contains('\0'))
2075            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2076        {
2077            return Err(NodeServerError::Registry(
2078                "H3B fixture provider or arguments are invalid".to_owned(),
2079            ));
2080        }
2081        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2082            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2083        })?.clone();
2084        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2085            .ok_or_else(|| NodeServerError::Registry(
2086                "H3B fixture provider basename is unavailable".to_owned(),
2087            ))?.to_owned();
2088        spec.detection.command = process_name.clone();
2089        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2090            name: process_name,
2091        }];
2092        spec.launch.program = program;
2093        spec.launch.fixed_args = fixed_args;
2094        Ok(spec)
2095    }
2096
2097    #[cfg(feature = "fixture")]
2098    pub fn new_harness_mcp_proxy_fixture(
2099        config: NodeServerConfig,
2100        provider_program: PathBuf,
2101        fixed_args: Vec<OsString>,
2102        harness_mcp_program: PathBuf,
2103    ) -> Result<Self, NodeServerError> {
2104        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2105        let agent_id = AgentId::new("codex")
2106            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2107        let mut spec =
2108            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2109        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2110        config.fixture_raw_pty_runtime = true;
2111        Self::new_fixture_with_spec(config, spec)
2112    }
2113
2114    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2115    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2116    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2117    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2118    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2119    /// fixture_raw_pty_runtime = true`, which tears out the live
2120    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2121    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2122    /// hardcode `acp_transport = false` for every provider, refusing
2123    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2124    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2125    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2126    /// is built from the (cloned, launcher-replaced) catalog spec, and
2127    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2128    /// unmodified catalog fact: every builtin provider this accepts
2129    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2130    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2131    /// invented here -- this constructor only removes the fixture-only
2132    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2133    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2134    /// for every ACP session, fixture or not) is what actually grants the
2135    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2136    /// this constructor never calls it directly, and does not need to.
2137    #[cfg(feature = "fixture")]
2138    pub fn new_harness_mcp_acp_launcher_fixture(
2139        config: NodeServerConfig,
2140        helper_program: PathBuf,
2141        provider: AgentId,
2142        program: PathBuf,
2143        args: Vec<OsString>,
2144    ) -> Result<Self, NodeServerError> {
2145        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2146            return Err(NodeServerError::Registry(
2147                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2148            ));
2149        }
2150        let config = config.with_harness_mcp_helper(helper_program)?;
2151        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2152        let launch = spec.launch.clone();
2153        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2154            NodeServerError::Registry(
2155                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2156                    .to_owned(),
2157            )
2158        })?;
2159        // Routes the ACP spawn itself through the exact replaced launcher
2160        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2161        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2162        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2163        // without this the ACP transport ignores `spec.launch` entirely and
2164        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2165        // codex-acp@1.10.0`), which this fixture must never reach.
2166        acp.launch_override = Some(launch);
2167        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2168        Self::new_fixture_with_spec(config, spec)
2169    }
2170
2171    #[cfg(feature = "fixture")]
2172    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2173        let mut spec = gate4agent_testkit::interactive_agent_spec();
2174        let claude_id = AgentId::new("claude")
2175            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176        let claude = builtin_registry()
2177            .get(&claude_id)
2178            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2179        spec.id = claude_id;
2180        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2181        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2182            name: "claude".to_owned(),
2183        }];
2184        spec.capabilities.transports.pty_adapter = claude
2185            .capabilities
2186            .transports
2187            .pty_adapter
2188            .clone();
2189        spec.capabilities.adapters.resume = claude
2190            .capabilities
2191            .adapters
2192            .resume
2193            .clone();
2194        let script = spec
2195            .launch
2196            .fixed_args
2197            .pop()
2198            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2199        spec.launch.fixed_args.push(format!(
2200            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2201        ));
2202        Self::new_fixture_with_spec(config, spec)
2203    }
2204
2205    #[cfg(feature = "fixture")]
2206    pub fn new_cmd_cwd_fixture(
2207        config: NodeServerConfig,
2208        command: String,
2209    ) -> Result<Self, NodeServerError> {
2210        let mut spec = gate4agent_testkit::interactive_agent_spec();
2211        spec.id = AgentId::new("claude")
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2214        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2215        spec.launch.program = command;
2216        spec.launch.fixed_args.clear();
2217        Self::new_fixture_with_spec(config, spec)
2218    }
2219
2220    #[cfg(feature = "fixture")]
2221    fn new_fixture_with_spec(
2222        config: NodeServerConfig,
2223        spec: gate4agent_types::AgentSpec,
2224    ) -> Result<Self, NodeServerError> {
2225        let catalog = AgentRegistry::new([spec])
2226            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2227        Self::new_with_registry(config, catalog)
2228    }
2229
2230    fn new_with_registry(
2231        config: NodeServerConfig,
2232        catalog: AgentRegistry,
2233    ) -> Result<Self, NodeServerError> {
2234        for profile in config.spawn_profiles.iter() {
2235            if catalog.get(&profile.provider).is_none() {
2236                return Err(NodeServerError::Registry(
2237                    "spawn profile references an unavailable provider".to_owned(),
2238                ));
2239            }
2240        }
2241        let input_settle_timeout_ms = catalog
2242            .iter()
2243            .map(|spec| spec.readiness.timeout_ms)
2244            .max()
2245            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2246            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2247            .max(MUTATION_SETTLE_TIMEOUT_MS);
2248        let (provider_contracts, provider_adapter_contracts) =
2249            provider_contract_manifest(&catalog)?;
2250        let compatibility_support = node_compatibility_support_for_manifest(
2251            &provider_contracts,
2252            &provider_adapter_contracts,
2253        )?;
2254        validate_node_negotiated_handshake_capacity(&compatibility_support)
2255            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2256        let enabled_providers = provider_contracts
2257            .iter()
2258            .map(|contract| contract.provider.clone())
2259            .collect();
2260        #[cfg(feature = "fixture")]
2261        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2262        #[cfg(not(feature = "fixture"))]
2263        let fixture_raw_pty_runtime = false;
2264        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2265            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2266                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2267            })).expect("fixture provider catalog remains bounded");
2268            (None, statuses)
2269        } else {
2270            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2271            let statuses = monitor.collect();
2272            (Some(monitor), statuses)
2273        };
2274        let state_path_lock =
2275            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2276                .map_err(|error| {
2277                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2278                })?;
2279        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2280            Some(state_path) => {
2281                let delivery_root = delivery_store_root_for_state_path(state_path)
2282                    .ok_or_else(|| {
2283                        NodeServerError::DeliveryStore(
2284                            "delivery store root could not be derived from state path".to_owned(),
2285                        )
2286                    })?;
2287                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2288                    let detail = match &error {
2289                        DeliveryStoreError::Storage(io_error) => {
2290                            format!("{error}: {io_error}")
2291                        }
2292                        _ => error.to_string(),
2293                    };
2294                    NodeServerError::DeliveryStore(detail)
2295                })?;
2296                (Some(store), bundles)
2297            }
2298            None => (None, Vec::new()),
2299        };
2300        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2301            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2302        })?;
2303        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2304            Some(state_path) => {
2305                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2306                    .ok_or(NodeServerError::ContextPackStore)?;
2307                let (store, packs) = ContextPackStore::open(context_pack_root)
2308                    .map_err(|_| NodeServerError::ContextPackStore)?;
2309                (Some(store), packs)
2310            }
2311            None => (None, Vec::new()),
2312        };
2313        let mut durable_context_catalog = ContextPackCatalog::default();
2314        for pack in durable_context_packs {
2315            durable_context_catalog
2316                .insert(pack)
2317                .map_err(|_| NodeServerError::ContextPackStore)?;
2318        }
2319        let session_environment_materializer = config
2320            .session_environment
2321            .as_ref()
2322            .map(|environment| {
2323                SessionEnvironmentMaterializer::new(
2324                    environment.root.clone(),
2325                    Arc::clone(&environment.resolver),
2326                )
2327                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2328            })
2329            .transpose()?;
2330        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2331        // (the provider version probe above already used the original catalog).
2332        #[cfg(unix)]
2333        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2334        let native_session_catalog = config.history.clone().map(|history| {
2335            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2336        });
2337        let (handle, runtime) = match config.history.clone() {
2338            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2339            None => NativeRuntime::new(catalog, config.runtime),
2340        };
2341        let native_launch_profile_control = runtime.native_launch_profile_control();
2342        let harness_mcp_registry = config
2343            .harness_mcp_helper
2344            .clone()
2345            .map(HarnessMcpProxyRegistry::new);
2346        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2347        let incarnation_id = random_incarnation_id()
2348            .map_err(NodeServerError::IncarnationIdentity)?;
2349        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2350            .map_err(durable_state_load_error)?;
2351        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2352        let managed_worktree_tombstones =
2353            std::mem::take(&mut loaded.managed_worktree_tombstones);
2354        let materializations = std::mem::take(&mut loaded.materializations);
2355        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2356        let (workspaces, records, persistence_warning) =
2357            merge_durable_state(&config.workspaces, loaded)?;
2358        let mut shared = NodeShared::new_with_incarnation(
2359            handle,
2360            config.access_token.clone(),
2361            config.node_id.clone(),
2362            incarnation_id,
2363            workspaces,
2364            enabled_providers,
2365            provider_runtime_statuses,
2366            provider_runtime_monitor,
2367            provider_contracts,
2368            provider_adapter_contracts,
2369            config.spawn_profiles.clone(),
2370            Some(native_launch_profile_control),
2371            native_session_catalog,
2372            session_environment_materializer,
2373            config.state_path.clone(),
2374            records,
2375            managed_worktrees,
2376            managed_worktree_tombstones,
2377            materializations,
2378            persistence_warning,
2379            input_settle_timeout_ms,
2380        );
2381        shared.harness_mcp_registry = harness_mcp_registry.clone();
2382        shared.session_record_retention = config.session_record_retention;
2383        shared.network_allowlist_catalog =
2384            RwLock::new(config.network_allowlist_catalog.clone());
2385        shared.bundle_catalog = RwLock::new(delivered_catalog);
2386        shared.delivery_store = Mutex::new(delivery_store);
2387        shared.context_catalog = RwLock::new(durable_context_catalog);
2388        shared.context_pack_store = Mutex::new(context_pack_store);
2389        shared.managed_spawn_replays = Mutex::new(
2390            managed_spawn_replays
2391                .into_iter()
2392                .map(|record| (record.idempotency_key.clone(), record))
2393                .collect(),
2394        );
2395        let shared = Arc::new(shared);
2396        if let Some(registry) = harness_mcp_registry {
2397            let weak = Arc::downgrade(&shared);
2398            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2399                Some(shared) => shared.publish_transient(event),
2400                None => false,
2401            }));
2402        }
2403        if shared.persistence_error().is_none() {
2404            shared.persist_state().map_err(|error| {
2405                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2406            })?;
2407        }
2408        Ok(Self {
2409            config,
2410            runtime,
2411            shared,
2412            events,
2413            state_path_lock,
2414        })
2415    }
2416
2417    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2418        NodeShutdownHandle {
2419            shared: Arc::clone(&self.shared),
2420        }
2421    }
2422
2423    #[cfg(feature = "fixture")]
2424    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2425        &self,
2426    ) -> SpawnManagedWorktreeV2FailureProbe {
2427        self.shared
2428            .fixture_spawn_managed_worktree_v2_failure_probe
2429            .clone()
2430    }
2431
2432    /// Installs one immutable host-local environment profile before the server runs.
2433    ///
2434    /// Native resolvers remain inside the runtime; the shared node registry keeps
2435    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2436    pub fn install_environment_profile(
2437        &mut self,
2438        profile: NodeEnvironmentProfile,
2439    ) -> Result<(), NodeServerError> {
2440        if !self.shared.enabled_providers.contains(profile.provider()) {
2441            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2442                profile.provider().clone(),
2443            ));
2444        }
2445        let (binding, native_profiles, materialization) = profile.into_parts();
2446        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2447            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2448        }
2449        let mut profiles = self
2450            .shared
2451            .environment_profiles
2452            .write()
2453            .unwrap_or_else(|poisoned| poisoned.into_inner());
2454        if profiles.contains_key(&binding.id) {
2455            return Err(NodeServerError::DuplicateEnvironmentProfile(
2456                binding.id.clone(),
2457            ));
2458        }
2459        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2460            return Err(NodeServerError::EnvironmentProfileCapacity {
2461                max: MAX_NODE_ENVIRONMENT_PROFILES,
2462            });
2463        }
2464        for native_id in binding.native_profile_ids() {
2465            if profiles.values().any(|current| {
2466                current.native_profile_ids().any(|current_id| current_id == native_id)
2467            }) {
2468                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2469                    native_id.to_string(),
2470                ));
2471            }
2472        }
2473
2474        let mut installed = Vec::with_capacity(native_profiles.len());
2475        for native_profile in native_profiles {
2476            let native_id = native_profile.id().clone();
2477            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2478                for installed_id in installed.into_iter().rev() {
2479                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2480                }
2481                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2482            }
2483            installed.push(native_id);
2484        }
2485        if let Some(materialization) = materialization {
2486            self.shared
2487                .environment_materialization_profiles
2488                .write()
2489                .unwrap_or_else(|poisoned| poisoned.into_inner())
2490                .insert(binding.id.clone(), materialization);
2491        }
2492        profiles.insert(binding.id.clone(), binding);
2493        Ok(())
2494    }
2495
2496    /// Installs one immutable host-local bundle before the server runs.
2497    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2498        if self.shared.session_environment_materializer.is_none() {
2499            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2500        }
2501        let mut catalog = self
2502            .shared
2503            .bundle_catalog
2504            .write()
2505            .unwrap_or_else(|poisoned| poisoned.into_inner());
2506        catalog
2507            .insert_idempotent(bundle)
2508            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2509        Ok(())
2510    }
2511
2512    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2513        let shutdown = self.shutdown_handle();
2514        let run = self.run();
2515        tokio::pin!(run);
2516        tokio::select! {
2517            result = &mut run => result,
2518            signal = wait_for_ctrl_signal() => {
2519                signal?;
2520                let shutdown_result = shutdown.request_shutdown().await;
2521                let run_result = run.await;
2522                shutdown_result.and(run_result)
2523            }
2524        }
2525    }
2526
2527    pub async fn run(self) -> Result<(), NodeServerError> {
2528        let NodeServer {
2529            config,
2530            mut runtime,
2531            shared,
2532            events,
2533            state_path_lock: _state_path_lock,
2534        } = self;
2535        shared.reconcile_materializations();
2536        shared.reconcile_managed_worktrees().await;
2537        shared.retire_unavailable_session_records().await;
2538        shared.reconcile_context_pack_exports().await;
2539        {
2540            let restore = Arc::clone(&shared);
2541            tokio::spawn(async move {
2542                sleep(Duration::from_secs(3)).await;
2543                restore.restore_journalled_sessions().await;
2544                loop {
2545                    restore.refresh_session_journal();
2546                    if restore.shutdown.load(Ordering::Acquire) { break; }
2547                    sleep(Duration::from_secs(10)).await;
2548                }
2549            });
2550        }
2551        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2552        // observes sessions through ACP where a provider has it; there is no
2553        // hook ingress.
2554        let endpoint = config.endpoint.clone();
2555        let call_home = config.call_home;
2556        let call_home_identity = config.call_home_identity.clone();
2557        let api_listen = config.api_listen;
2558        let bridge_listen = config.bridge_listen;
2559        let bridge_underlay = config.bridge_underlay;
2560        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2561            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2562        let accept_shared = Arc::clone(&shared);
2563        let api_shared = Arc::clone(&shared);
2564        let bridge_shared = Arc::clone(&shared);
2565        let shutdown_shared = Arc::clone(&shared);
2566        let shutdown_timeout = Duration::from_millis(
2567            config.runtime.provider_shutdown_timeout_ms.max(1),
2568        );
2569        let result = {
2570            let runtime_loop = drive_runtime_until_shutdown(
2571                &mut runtime,
2572                events,
2573                Arc::clone(&shared),
2574                shutdown_timeout,
2575            );
2576            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2577            let api_loop = http_api::run(api_listen, api_shared);
2578            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2579            tokio::pin!(runtime_loop);
2580            tokio::pin!(accept_loop);
2581            tokio::pin!(api_loop);
2582            tokio::pin!(bridge_loop);
2583            tokio::select! {
2584                runtime_result = &mut runtime_loop => {
2585                    let shutdown_result = shutdown_shared
2586                        .begin_shutdown()
2587                        .await
2588                        .map_err(NodeServerError::ShutdownDispatch);
2589                    let (accept_result, api_result, bridge_result) =
2590                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2591                    match runtime_result {
2592                        Err(error) => Err(error),
2593                        Ok(()) => shutdown_result
2594                            .and(accept_result)
2595                            .and(api_result.map_err(NodeServerError::HttpApi))
2596                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2597                    }
2598                }
2599                accept_result = &mut accept_loop => {
2600                    let shutdown_result = shutdown_shared
2601                        .begin_shutdown()
2602                        .await
2603                        .map_err(NodeServerError::ShutdownDispatch);
2604                    let (runtime_result, api_result, bridge_result) =
2605                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2606                    match accept_result {
2607                        Err(error) => Err(error),
2608                        Ok(()) => shutdown_result
2609                            .and(runtime_result)
2610                            .and(api_result.map_err(NodeServerError::HttpApi))
2611                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2612                    }
2613                }
2614                api_result = &mut api_loop => {
2615                    let shutdown_result = shutdown_shared
2616                        .begin_shutdown()
2617                        .await
2618                        .map_err(NodeServerError::ShutdownDispatch);
2619                    let (runtime_result, accept_result, bridge_result) =
2620                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2621                    match api_result {
2622                        Err(error) => Err(NodeServerError::HttpApi(error)),
2623                        Ok(()) => shutdown_result
2624                            .and(runtime_result)
2625                            .and(accept_result)
2626                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2627                    }
2628                }
2629                bridge_result = &mut bridge_loop => {
2630                    let shutdown_result = shutdown_shared
2631                        .begin_shutdown()
2632                        .await
2633                        .map_err(NodeServerError::ShutdownDispatch);
2634                    let (runtime_result, accept_result, api_result) =
2635                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2636                    match bridge_result {
2637                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2638                        Ok(()) => shutdown_result
2639                            .and(runtime_result)
2640                            .and(accept_result)
2641                            .and(api_result.map_err(NodeServerError::HttpApi)),
2642                    }
2643                }
2644            }
2645        };
2646        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2647        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2648        // NodeShutdownHandle also clones that Arc, so without an explicit release
2649        // here a supervisor that keeps the shutdown handle cannot reopen the same
2650        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2651        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2652        if let Ok(mut guard) = shared.delivery_store.lock() {
2653            *guard = None;
2654        }
2655        if let Ok(mut guard) = shared.context_pack_store.lock() {
2656            *guard = None;
2657        }
2658        result
2659    }
2660}
2661
2662async fn drive_runtime_until_shutdown(
2663    runtime: &mut NativeRuntime,
2664    events: EventSubscription,
2665    shared: Arc<NodeShared>,
2666    shutdown_timeout: Duration,
2667) -> Result<(), NodeServerError> {
2668    // Published once, never per iteration. The worker loops inside
2669    // `NativeRuntime` fold into this profile continuously; turning it into
2670    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2671    let _ = shared
2672        .shell_efficiency_profile
2673        .set(runtime.shell_efficiency_profile());
2674    let mut shutdown_started = None;
2675    let mut last_retention_sweep = Instant::now();
2676    let mut last_provisional_reap = Instant::now();
2677    loop {
2678        // Every phase below is timed unconditionally -- see
2679        // `drive_loop_profile`'s own doc comment for why an always-on,
2680        // allocation-free ring is cheap enough to never need a feature
2681        // flag. This is the only place these numbers can be taken: the
2682        // phases are calls made directly from this loop, not something a
2683        // callee could time on our behalf.
2684        let runtime_tick_start = Instant::now();
2685        let tick_result = runtime.tick().await;
2686        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2687
2688        let event_drain_start = Instant::now();
2689        // Every command the kernel refused, named. `NativeRuntimeTick`
2690        // has carried these outcomes all along and nothing read them, so a
2691        // rejected command -- a hook event denied by runtime policy, a
2692        // stale generation, an unsupported capability -- vanished at the
2693        // last step of the loop that produced it. A refusal the operator
2694        // cannot see is indistinguishable from an event that never arrived,
2695        // and telling those two apart is most of debugging this stack.
2696        for outcome in &tick_result.command_outcomes {
2697            if let Err(error) = &outcome.result {
2698                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2699                tracing::warn!(
2700                    command_id = outcome.command_id.0,
2701                    cause = %error,
2702                    "control command rejected"
2703                );
2704                shared.record_rejected_command(
2705                    outcome.command_id,
2706                    error.to_string(),
2707                    error.is_unsupported_transport(),
2708                );
2709            }
2710        }
2711        let mut events_drained: u64 = 0;
2712        while let Ok(event) = events.try_recv() {
2713            events_drained += 1;
2714            let clean_exit = matches!(
2715                event.event,
2716                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2717            );
2718            let instance_id = event.instance_id;
2719            let generation = event.generation;
2720            // Resolve and spawn before publish_control: publish_control's own
2721            // managed-record bookkeeping (reconcile_managed_record) downgrades
2722            // this exact record from Live to Dormant and clears
2723            // active_session for every Exited event, inside the same
2724            // synchronous call. Capturing (record_id, session) here — before
2725            // that runs — is the only way to observe them still Live and
2726            // still populated; the export itself must still run on its own
2727            // detached task (never awaited inline on this loop) because it
2728            // needs NativeRuntime::tick() to keep running to ever settle, and
2729            // tick() is only ever driven by this same loop. See
2730            // reconcile_context_pack_export_for_record's doc comment.
2731            if clean_exit {
2732                if let Some((record_id, session)) =
2733                    shared.managed_record_export_target_for_instance(instance_id, generation)
2734                {
2735                    let export_shared = Arc::clone(&shared);
2736                    tokio::spawn(async move {
2737                        export_shared
2738                            .reconcile_context_pack_export_for_record(&record_id, session)
2739                            .await;
2740                    });
2741                }
2742            }
2743            shared.publish_control(event);
2744        }
2745        let event_drain_elapsed = event_drain_start.elapsed();
2746
2747        let publish_terminal_frames_start = Instant::now();
2748        shared.publish_terminal_frames();
2749        shared.run_due_provider_retries();
2750        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2751
2752        // Physical PTY count and the runtime's own per-phase tick timings
2753        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2754        // only `NodeShared`) cannot reach directly -- this loop is the one
2755        // place holding `&mut NativeRuntime`, so it is the only place that
2756        // can copy either out.
2757        shared
2758            .native_session_gauge
2759            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2760        *shared
2761            .runtime_tick_profile
2762            .lock()
2763            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2764
2765        let remainder_start = Instant::now();
2766        if shared.shutdown.load(Ordering::Acquire) {
2767            let started = *shutdown_started.get_or_insert_with(Instant::now);
2768            let snapshot = shared.handle.snapshot();
2769            let sessions_settled = snapshot.sessions.iter().all(|session| {
2770                !matches!(
2771                    session.status,
2772                    gate4agent_types::SessionStatus::Starting
2773                        | gate4agent_types::SessionStatus::Running
2774                        | gate4agent_types::SessionStatus::Stopping
2775                )
2776            });
2777            if runtime.active_native_sessions() == 0 && sessions_settled {
2778                return Ok(());
2779            }
2780            if started.elapsed() >= shutdown_timeout {
2781                return Err(NodeServerError::ShutdownTimedOut {
2782                    active_native_sessions: runtime.active_native_sessions(),
2783                });
2784            }
2785        }
2786        let remainder_elapsed = remainder_start.elapsed();
2787
2788        {
2789            let mut profile = shared
2790                .drive_loop_profile
2791                .lock()
2792                .unwrap_or_else(|poisoned| poisoned.into_inner());
2793            profile.record_runtime_tick(runtime_tick_elapsed);
2794            profile.record_event_drain(event_drain_elapsed);
2795            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2796            profile.record_remainder(remainder_elapsed);
2797            profile.note_iteration(Instant::now());
2798        }
2799
2800        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2801        // applied zero observations (`tick_result.observations_applied`,
2802        // which already folds in any terminal frames collected -- see
2803        // `NativeEffectDispatcher::drain_observations`) AND the
2804        // control-event subscription drained above forwarded zero events.
2805        // Command ingress and dispatched effects are deliberately NOT part
2806        // of this definition: an idle node still runs `step_control_plane`
2807        // and every provider supervisor's `tick` regardless, every 10ms --
2808        // so "nothing" here means nothing OBSERVABLE left this iteration,
2809        // matching what a reader would assume from `drive_loop_iterations`'s
2810        // own field name, not that the tick itself was free of work.
2811        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2812        if tick_result.observations_applied == 0 && events_drained == 0 {
2813            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2814        }
2815
2816        // A sweep cadence, not a policy value -- see
2817        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2818        // policy itself (whether this does anything at all) lives entirely
2819        // in `NodeShared::session_record_retention`.
2820        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2821            shared.retire_unavailable_session_records().await;
2822            last_retention_sweep = Instant::now();
2823        }
2824        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2825        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2826            for address in shared.take_due_provisional_spawns() {
2827                shared.reap_provisional_spawn(address).await;
2828            }
2829            last_provisional_reap = Instant::now();
2830        }
2831
2832        // Fast-poll for 300ms after PTY input so its echo is read and published
2833        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2834        let hot = shared
2835            .pty_input_marks
2836            .lock()
2837            .unwrap_or_else(|poisoned| poisoned.into_inner())
2838            .values()
2839            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2840        if hot {
2841            sleep(Duration::from_millis(1)).await;
2842        } else {
2843            tokio::select! {
2844                _ = sleep(Duration::from_millis(10)) => {}
2845                _ = shared.pty_input_wake.notified() => {}
2846            }
2847        }
2848    }
2849}
2850
2851#[derive(Clone)]
2852pub struct NodeShutdownHandle {
2853    shared: Arc<NodeShared>,
2854}
2855
2856impl NodeShutdownHandle {
2857    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2858        self.shared
2859            .begin_shutdown()
2860            .await
2861            .map_err(NodeServerError::ShutdownDispatch)
2862    }
2863}
2864
2865async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2866    #[cfg(windows)]
2867    {
2868    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2869    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2870    return tokio::select! {
2871        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2873    };
2874    }
2875    #[cfg(unix)]
2876    {
2877        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2878        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2879        tokio::select! {
2880            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2881            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2882        }
2883    }
2884}
2885
2886/// The providers this node actually runs.
2887///
2888/// `builtin_registry()` is a reference catalog of everything the project has
2889/// ever described; the fleet is this list, and it is deliberately short. A
2890/// provider only belongs here if it can be exercised for real -- there is a
2891/// subscription behind it and a failure in it can be reproduced and
2892/// debugged. Carrying one that cannot is carrying code nobody can verify,
2893/// which is worse than not supporting it: it looks supported.
2894///
2895/// A provider dropped from the fleet by this rule is removed outright, not
2896/// carried dark: no catalog entry, no adapter, no dead code claiming support
2897/// for something nobody can verify runs.
2898/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2899fn shell_host_spec() -> AgentSpec {
2900    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2901    // fleet inventory on op-box resolves `bash` to the WSL stub at
2902    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2903    #[cfg(windows)]
2904    let (detect_command, program, fixed_args, process_name) = {
2905        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2906            r"C:\Windows\System32\cmd.exe".to_owned()
2907        });
2908        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2909    };
2910    #[cfg(not(windows))]
2911    let (detect_command, program, fixed_args, process_name) = (
2912        "bash".to_owned(),
2913        "/bin/bash".to_owned(),
2914        vec!["-i".to_owned()],
2915        "bash".to_owned(),
2916    );
2917    AgentSpec {
2918        id: AgentId::new("shell").expect("shell agent id"),
2919        revision: "hatchery:shell-pty-1".to_owned(),
2920        display_name: "Host shell".to_owned(),
2921        detection: DetectionSpec {
2922            command: detect_command,
2923            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2924            required_commands: Vec::new(),
2925            unsupported_platforms: Vec::new(),
2926        },
2927        launch: LaunchSpec {
2928            program,
2929            fixed_args,
2930        },
2931        expected_processes: vec![ProcessMatcher::Exact {
2932            name: process_name,
2933        }],
2934        prompt: PromptSpec {
2935            initial: InitialPromptMode::None,
2936            native_draft: None,
2937        },
2938        readiness: AgentReadinessSpec::default(),
2939        capabilities: AgentCapabilities {
2940            agent_commands: None,
2941            transports: AgentTransportCapabilities {
2942                pty: true,
2943                pty_adapter: None,
2944                pipe: None,
2945                acp: None,
2946            },
2947            adapters: AgentAdapterCapabilities::default(),
2948        },
2949        verification: SpecVerification::Gate4AgentVerified,
2950    }
2951}
2952
2953fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2954    let mut specs = vec![shell_host_spec()];
2955    specs.extend(
2956        builtin_registry()
2957            .iter()
2958            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2959            .cloned(),
2960    );
2961    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2962}
2963
2964fn provider_contract_manifest(
2965    catalog: &AgentRegistry,
2966) -> Result<
2967    (
2968        Vec<ProviderContractSupport>,
2969        Vec<ProviderAdapterContractSupport>,
2970    ),
2971    NodeServerError,
2972> {
2973    let mut provider_contracts = Vec::new();
2974    let mut provider_adapter_contracts = Vec::new();
2975    for spec in catalog.iter() {
2976        let provider = spec.id.clone();
2977        let revision = ProviderContractRevision::new(spec.revision.clone())
2978            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2979        provider_contracts.push(ProviderContractSupport {
2980            provider: provider.clone(),
2981            revision,
2982        });
2983        for (family, binding) in declared_adapter_bindings(spec)? {
2984            let revision = AdapterContractRevision::new(binding.revision.clone())
2985                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2987                provider: provider.clone(),
2988                family,
2989                adapter_id: binding.id.clone(),
2990                revision,
2991            });
2992        }
2993    }
2994    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2995        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2996    Ok((provider_contracts, provider_adapter_contracts))
2997}
2998
2999fn declared_adapter_bindings(
3000    spec: &AgentSpec,
3001) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3002    let mut bindings = Vec::new();
3003    push_declared_binding(
3004        &mut bindings,
3005        AdapterFamily::PtySemantic,
3006        spec.capabilities.transports.pty_adapter.as_ref(),
3007    )?;
3008    push_declared_binding(
3009        &mut bindings,
3010        AdapterFamily::Pipe,
3011        spec.capabilities.adapters.pty_sidecar.as_ref(),
3012    )?;
3013    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3014    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3015        gate4agent_types::PipeProtocol::SemanticNdjson
3016        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3017        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3018    });
3019    push_declared_binding(
3020        &mut bindings,
3021        AdapterFamily::Pipe,
3022        pipe_transport
3023            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3024            .map(|transport| &transport.adapter),
3025    )?;
3026    push_declared_binding(
3027        &mut bindings,
3028        AdapterFamily::Acp,
3029        spec.capabilities
3030            .transports
3031            .acp
3032            .as_ref()
3033            .map(|transport| &transport.adapter),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::Hook,
3038        spec.capabilities.adapters.hook.as_ref(),
3039    )?;
3040    push_declared_binding(
3041        &mut bindings,
3042        AdapterFamily::ManagedHook,
3043        spec.capabilities.adapters.managed_hook.as_ref(),
3044    )?;
3045    push_declared_binding(
3046        &mut bindings,
3047        AdapterFamily::OneShot,
3048        pipe_transport
3049            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3050            .map(|transport| &transport.adapter),
3051    )?;
3052    push_declared_binding(
3053        &mut bindings,
3054        AdapterFamily::OneShot,
3055        spec.capabilities.adapters.one_shot.as_ref(),
3056    )?;
3057    push_declared_binding(
3058        &mut bindings,
3059        AdapterFamily::History,
3060        spec.capabilities.adapters.history.as_ref(),
3061    )?;
3062    push_declared_binding(
3063        &mut bindings,
3064        AdapterFamily::Resume,
3065        spec.capabilities.adapters.resume.as_ref(),
3066    )?;
3067    push_declared_binding(
3068        &mut bindings,
3069        AdapterFamily::SessionOptions,
3070        spec.capabilities.adapters.session_options.as_ref(),
3071    )?;
3072    push_declared_binding(
3073        &mut bindings,
3074        AdapterFamily::CapabilityProbe,
3075        spec.capabilities.adapters.capability_probe.as_ref(),
3076    )?;
3077    Ok(bindings)
3078}
3079
3080fn push_declared_binding<'a>(
3081    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3082    family: AdapterFamily,
3083    binding: Option<&'a AdapterBinding>,
3084) -> Result<(), NodeServerError> {
3085    let Some(binding) = binding else {
3086        return Ok(());
3087    };
3088    if let Some((_, existing)) = bindings
3089        .iter()
3090        .find(|(existing_family, _)| *existing_family == family)
3091    {
3092        if *existing == binding {
3093            return Ok(());
3094        }
3095        return Err(NodeServerError::ProviderContractManifest(format!(
3096            "provider declares conflicting {family:?} adapter bindings"
3097        )));
3098    }
3099    bindings.push((family, binding));
3100    Ok(())
3101}
3102
3103fn merge_durable_state(
3104    configured: &[WorkspaceConfig],
3105    mut loaded: LoadedNodeState,
3106) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3107    let mut workspaces = configured
3108        .iter()
3109        .cloned()
3110        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3111        .collect::<BTreeMap<_, _>>();
3112    let mut roots = workspaces
3113        .values()
3114        .map(|workspace| {
3115            (
3116                platform::root_identity(&workspace.canonical_root),
3117                workspace.workspace_id.clone(),
3118            )
3119        })
3120        .collect::<BTreeMap<_, _>>();
3121    let mut warning = loaded
3122        .warning
3123        .take()
3124        .map(|message| session_registry::sanitized_persistence_summary(&message));
3125
3126    for (workspace_id, canonical_root) in loaded.workspaces {
3127        if let Some(existing) = workspaces.get(&workspace_id) {
3128            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3129                return Err(NodeServerError::DurableState(io::Error::new(
3130                    io::ErrorKind::InvalidData,
3131                    DURABLE_STATE_CONFLICT_ERROR,
3132                )));
3133            }
3134            continue;
3135        }
3136        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3137            return Err(NodeServerError::DurableState(io::Error::new(
3138                io::ErrorKind::InvalidData,
3139                DURABLE_STATE_CONFLICT_ERROR,
3140            )));
3141        }
3142        if !Path::new(&canonical_root).is_dir() {
3143            warning.get_or_insert_with(|| {
3144                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3145            });
3146            continue;
3147        }
3148        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3149            NodeServerError::DurableState(io::Error::new(
3150                io::ErrorKind::InvalidData,
3151                DURABLE_STATE_CONFLICT_ERROR,
3152            ))
3153        })?;
3154        roots.insert(
3155            platform::root_identity(&workspace.canonical_root),
3156            workspace.workspace_id.clone(),
3157        );
3158        workspaces.insert(workspace_id, workspace);
3159    }
3160
3161    for record in &mut loaded.records {
3162        match workspaces.get(&record.workspace_id) {
3163            Some(workspace)
3164                if platform::roots_equal(
3165                    &workspace.canonical_root,
3166                    windows_path_text(&record.canonical_root),
3167                ) => {}
3168            Some(_) => {
3169                return Err(NodeServerError::DurableState(io::Error::new(
3170                    io::ErrorKind::InvalidData,
3171                    DURABLE_STATE_CONFLICT_ERROR,
3172                )));
3173            }
3174            None => {
3175                record.state = ManagedSessionState::Unavailable;
3176                record.active_session = None;
3177                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3178            }
3179        }
3180    }
3181    Ok((workspaces.into_values().collect(), loaded.records, warning))
3182}
3183
3184/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3185/// own doc for the mechanism this exists to close.
3186struct RejectedCommandReason {
3187    message: String,
3188    /// True when the kernel refused this command specifically because the
3189    /// agent does not declare the requested transport (`KernelCommandError::
3190    /// UnsupportedTransport`) -- lets the reader choose the named
3191    /// `NodeFailureCode::UnsupportedTransport` over the generic
3192    /// `BackendOperationFailed` bucket, so this one well-known rejection
3193    /// keeps its identity all the way to the operator wire.
3194    unsupported_transport: bool,
3195    expires_at: Instant,
3196}
3197
3198struct NodeShared {
3199    handle: Gate4AgentHandle,
3200    access_token: String,
3201    node_id: NodeId,
3202    incarnation_id: NodeIncarnationId,
3203    started_at_unix_ms: u64,
3204    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3205    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3206    managed_worktree_profiles:
3207        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3208    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3209    enabled_providers: Vec<AgentId>,
3210    provider_runtime_statuses: ProviderRuntimeStatuses,
3211    provider_runtime_status_updates:
3212        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3213    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3214    provider_contracts: Vec<ProviderContractSupport>,
3215    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3216    spawn_profiles: SpawnProfileRegistry,
3217    environment_profiles:
3218        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3219    environment_materialization_profiles:
3220        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3221    /// Station **network** allowlist catalog (opaque policy ids only).
3222    /// Empty by default — any `network_allowlist` on spawn must be registered
3223    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3224    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3225    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3226    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3227    /// Never holds cookies / OAuth / proxy credentials.
3228    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3229    /// Node-local exclusive dig2browser station profile leases (feature
3230    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3231    /// cleared on session end. Never ImportSession / cookies on C2.
3232    #[cfg(feature = "dig2-station-probe")]
3233    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3234    bundle_catalog: RwLock<BundleCatalog>,
3235    delivery_store: Mutex<Option<DeliveryStore>>,
3236    context_pack_store: Mutex<Option<ContextPackStore>>,
3237    context_catalog: RwLock<ContextPackCatalog>,
3238    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3239    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3240    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3241    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3242    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3243    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3244    /// nick -> the session that holds that m4a account (for letter wakes).
3245    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3246    /// m4a session key per runtime this node gave an account.
3247    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3248    /// Keys a resume hands to its new runtime (same nick and mailbox).
3249    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3250    /// Per-session grok leader sockets (never the operator's shared leader).
3251    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3252    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3253    /// instead of a bearer capability.
3254    #[cfg(unix)]
3255    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3256    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3257    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3258    managed_spawn_replays:
3259        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3260    controller: Mutex<Option<ControllerLease>>,
3261    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3262    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3263    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3264    /// ACP launch options waiting for the agent to advertise its config options.
3265    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3266    history: Mutex<NodeEventHistory>,
3267    event_tx: broadcast::Sender<NodeEventEnvelope>,
3268    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3269    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3270    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3271    /// exactly rather than riding the durable `event_tx` history (see
3272    /// `publish`'s own assertion): a subscriber who never negotiated
3273    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3274    /// these, the same guarantee `include_terminal_frame_events` already
3275    /// gives terminal frames.
3276    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3277    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3278    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3279    /// broadcast rather than a ride on the durable `event_tx` history. A
3280    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3281    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3282    /// finite capacity and its willingness to silently evict a lagging
3283    /// subscriber's backlog is exactly how the call vanished with neither
3284    /// side ever logging it (see `publish_transient`'s own doc).
3285    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3286    terminal_frame_watermarks:
3287        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3288    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3289    pty_input_wake: tokio::sync::Notify,
3290    /// Per session: when the last PTY input was dispatched (latency probe +
3291    /// a short fast-poll window so the echo is published within ~1ms).
3292    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3293    /// Provider error classifier + retry state per session (see `provider_error`).
3294    provider_errors: Mutex<crate::provider_error::Tracker>,
3295    next_connection_id: AtomicU64,
3296    next_instance_id: AtomicU64,
3297    next_command_id: AtomicU64,
3298    shutdown: AtomicBool,
3299    shutdown_notify: Notify,
3300    preauth_slots: Arc<Semaphore>,
3301    authenticated_slots: Arc<Semaphore>,
3302    inspection_slots: Arc<Semaphore>,
3303    mutation_gate: AsyncMutex<()>,
3304    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3305    session_records: Mutex<SessionRecords>,
3306    state_path: Option<PathBuf>,
3307    persistence_error: RwLock<Option<String>>,
3308    state_transaction: Mutex<()>,
3309    input_settle_timeout_ms: u64,
3310    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3311    /// comment. Written once per iteration by
3312    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3313    drive_loop_profile: Mutex<DriveLoopProfiler>,
3314    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3315    /// so an overwrite each iteration is cheap. The runtime itself is not
3316    /// reachable from here (it is owned locally by
3317    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3318    /// tick timings reach `GET /metrics`.
3319    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3320    /// The live shell-efficiency profile, shared with the per-instance
3321    /// worker loops that fold into it.
3322    ///
3323    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3324    /// profile` above keeps: producing a snapshot sorts every ring behind
3325    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3326    /// field set at construction because `NodeShared` is built before the
3327    /// `NativeRuntime` that owns the profile exists -- the drive loop
3328    /// publishes it once, before its first iteration. Unset means the
3329    /// runtime has not started driving yet, which `GET /metrics` reports as
3330    /// empty series rather than as zeroes it never measured.
3331    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3332    /// Physical PTY sessions the native runtime currently owns
3333    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3334    /// loop iteration -- deliberately a separate count from
3335    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3336    /// session bookkeeping), since the two can disagree and the gap is
3337    /// itself a diagnostic signal `GET /metrics` exposes.
3338    native_session_gauge: AtomicUsize,
3339    /// Lifetime totals for the per-connection serve loop, summed across
3340    /// every live connection.
3341    ///
3342    /// The drive loop's own phase timings account for about ten
3343    /// microseconds an iteration on an idle node, yet the process burns a
3344    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3345    /// with none, 23% with two, measured). That gap is somewhere in
3346    /// serving a connection, and there was no way to see it: iterations
3347    /// says whether the loop is spinning rather than sleeping, and events
3348    /// says whether it is spinning over real work or over nothing.
3349    connection_loop_iterations: AtomicU64,
3350    connection_events_sent: AtomicU64,
3351    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3352    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3353    /// `NativeRuntime::tick` observations AND forwarded zero control
3354    /// events; see the exact "produced nothing" definition at the site in
3355    /// `drive_runtime_until_shutdown` that increments these. Read twice
3356    /// and divide by the interval, same rule as `connections` above --
3357    /// `idle / total` is backlog item 5's "wake on work" number.
3358    /// Lifetime count of control commands the kernel refused.
3359    rejected_commands_total: AtomicU64,
3360    /// The named reason behind a recent kernel rejection, by the
3361    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3362    /// command -- populated by `drive_runtime_until_shutdown` for every
3363    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3364    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3365    /// `CommandId` among them.
3366    ///
3367    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3368    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3369    /// problem -- full or disconnected -- never for the kernel refusing the
3370    /// command's content (an unsupported transport, an unknown agent, ...);
3371    /// that verdict exists only once `NativeRuntime::tick` has processed the
3372    /// command, one drive-loop iteration later, in a different task than
3373    /// whichever caller is awaiting the spawn. Without this map, a spawn
3374    /// wait loop that polls `self.handle.snapshot()` for a session that
3375    /// will now never appear has no way to learn why -- it can only spin
3376    /// until its own commit deadline and report a generic "did not commit"
3377    /// timeout, discarding a reason the kernel already named precisely (see
3378    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3379    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3380    drive_loop_iterations_total: AtomicU64,
3381    drive_loop_iterations_idle: AtomicU64,
3382    /// The operator-chosen age/keep-N policy the retention sweep applies to
3383    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3384    /// see `SessionRecordRetentionConfig`'s own doc comment.
3385    session_record_retention: SessionRecordRetentionConfig,
3386    /// Lifetime count of managed session records the retention sweep has
3387    /// retired. Same tier as `rejected_commands_total` above: a live,
3388    /// process-lifetime instrument, never persisted and never reloaded from
3389    /// `state-v1.json` -- a restart legitimately resets it, matching every
3390    /// other `*_total` counter here.
3391    retired_records_total: AtomicU64,
3392    #[cfg(feature = "fixture")]
3393    fixture_semantic_hook_policy: bool,
3394    #[cfg(feature = "fixture")]
3395    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3396}
3397
3398impl Drop for NodeShared {
3399    fn drop(&mut self) {
3400        SessionLifecycleHook::on_shutdown(self);
3401    }
3402}
3403
3404#[derive(Clone, Debug, Eq, PartialEq)]
3405struct SessionBinding {
3406    workspace_id: WorkspaceId,
3407    generation: SessionGeneration,
3408    runtime_policy: ProviderRuntimePolicy,
3409    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3410    record_id: Option<SessionRecordId>,
3411    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3412    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3413    bundle: Option<ResolvedBundleReceipt>,
3414    context: Option<ResolvedContextPackReceipt>,
3415    materialization_id: Option<MaterializationId>,
3416}
3417
3418#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3419enum SpawnRecordPolicy {
3420    ProviderIdentityOnly,
3421    Always,
3422}
3423
3424struct NativeEnvironmentSelectionGuard {
3425    control: Option<NativeLaunchProfileControl>,
3426    instance_id: AgentInstanceId,
3427}
3428
3429struct NativeInstanceOverlayGuard {
3430    control: Option<NativeLaunchProfileControl>,
3431    instance_id: AgentInstanceId,
3432}
3433
3434struct NativeHarnessMcpOverlayGuard {
3435    control: NativeLaunchProfileControl,
3436    instance_id: AgentInstanceId,
3437}
3438
3439impl NativeHarnessMcpOverlayGuard {
3440    fn retain(self) { std::mem::forget(self); }
3441}
3442
3443impl Drop for NativeHarnessMcpOverlayGuard {
3444    fn drop(&mut self) {
3445        self.control
3446            .clear_native_mcp_server_launch_overlay(self.instance_id);
3447    }
3448}
3449
3450impl NativeInstanceOverlayGuard {
3451    fn retain(mut self) {
3452        self.control = None;
3453    }
3454}
3455
3456impl Drop for NativeInstanceOverlayGuard {
3457    fn drop(&mut self) {
3458        if let Some(control) = self.control.as_ref() {
3459            control.clear_native_instance_launch_overlay(self.instance_id);
3460        }
3461    }
3462}
3463
3464enum PreparedNativeLaunchOverlay {
3465    Environment(NativeLaunchEnvironmentOverlay),
3466    Instance(NativeInstanceLaunchOverlay),
3467}
3468
3469impl NativeEnvironmentSelectionGuard {
3470    fn retain(mut self) {
3471        self.control = None;
3472    }
3473}
3474
3475impl Drop for NativeEnvironmentSelectionGuard {
3476    fn drop(&mut self) {
3477        if let Some(control) = self.control.as_ref() {
3478            control.clear_native_launch_profile_selection(self.instance_id);
3479        }
3480    }
3481}
3482
3483struct SessionMaterializationGuard<'a> {
3484    shared: &'a NodeShared,
3485    id: Option<MaterializationId>,
3486    /// Absolute ProviderHome (`home/`) under the session materialization root.
3487    /// Used for Claude station network `--settings` sidecar writes.
3488    provider_home: Option<PathBuf>,
3489}
3490
3491impl SessionMaterializationGuard<'_> {
3492    fn id(&self) -> Option<&MaterializationId> {
3493        self.id.as_ref()
3494    }
3495
3496    fn provider_home(&self) -> Option<&Path> {
3497        self.provider_home.as_deref()
3498    }
3499
3500    fn retain(mut self) {
3501        self.id = None;
3502    }
3503}
3504
3505impl Drop for SessionMaterializationGuard<'_> {
3506    fn drop(&mut self) {
3507        if let Some(id) = self.id.take() {
3508            let _ = self.shared.cleanup_materialization(&id);
3509        }
3510    }
3511}
3512
3513
3514/// Port: product-side reactions to session lifecycle transitions
3515/// (station lease, harness MCP overlay/registry, environment launch overlays).
3516trait SessionLifecycleHook {
3517    /// A binding was removed (unbind or spawn rollback).
3518    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3519    /// A session reached a terminal control event that revokes its harness MCP door.
3520    fn on_session_ended(&self, address: &SessionAddress);
3521    /// Node shutdown (or drop): revoke every harness MCP reservation.
3522    fn on_shutdown(&self);
3523    /// A harness MCP reservation was aborted before activation.
3524    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3525    /// Release session-owned materialization for a removed binding.
3526    fn cleanup_session_materialization(
3527        &self,
3528        address: &SessionAddress,
3529        binding: &SessionBinding,
3530    ) -> Result<(), NodeFailure>;
3531}
3532
3533impl SessionLifecycleHook for NodeShared {
3534    fn on_session_ended(&self, address: &SessionAddress) {
3535        self.retire_session_grok_leader(address.session.instance_id);
3536        if !self.shutdown.load(Ordering::Acquire) {
3537            if let Some(file) = self.session_resume_journal() {
3538                crate::pty_resume::remove(&file, address.session.instance_id.0);
3539            }
3540        }
3541        if let Some(accounts) = self.m4a_accounts.as_ref() {
3542            let instance = address.session.instance_id;
3543            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3544            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3545                accounts.remove_session_file(nick);
3546            }
3547        }
3548        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549            registry.revoke_session(address);
3550        }
3551        if let Some(control) = self.native_launch_profile_control.as_ref() {
3552            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3553        }
3554    }
3555
3556    fn on_shutdown(&self) {
3557        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3558            .into_values()
3559            .collect();
3560        for socket in leaders {
3561            crate::grok_leader::retire(&socket);
3562        }
3563        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3564            let instances = registry.shutdown();
3565            if let Some(control) = self.native_launch_profile_control.as_ref() {
3566                for instance_id in instances {
3567                    control.clear_native_mcp_server_launch_overlay(instance_id);
3568                }
3569            }
3570        }
3571    }
3572
3573    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3574        if let Some(control) = self.native_launch_profile_control.as_ref() {
3575            control.clear_native_mcp_server_launch_overlay(instance_id);
3576        }
3577    }
3578
3579    fn cleanup_session_materialization(
3580        &self,
3581        address: &SessionAddress,
3582        binding: &SessionBinding,
3583    ) -> Result<(), NodeFailure> {
3584        self.cleanup_session_owned_materialization(address, binding)
3585    }
3586
3587    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3588        // Exclusive dig2browser station profile lease is node-local; clear
3589        // on every session unbind / rollback. Never ImportSession/cookies.
3590        #[cfg(feature = "dig2-station-probe")]
3591        {
3592            crate::dig2_station_lease::release_lease_for_holder(
3593                &self.browser_station_leases,
3594                address.session.instance_id,
3595            );
3596        }
3597        if let Some(control) = self.native_launch_profile_control.as_ref() {
3598            control.clear_native_mcp_server_launch_overlay(
3599                address.session.instance_id,
3600            );
3601        }
3602        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3603            registry.revoke_session(address);
3604        }
3605        if removed
3606            .is_some_and(|binding| {
3607                binding.environment_profile.is_some()
3608                    || binding.bundle.is_some()
3609                    || binding.context.is_some()
3610            })
3611        {
3612            if let Some(control) = self.native_launch_profile_control.as_ref() {
3613                control.clear_native_launch_profile_selection(
3614                    address.session.instance_id,
3615                );
3616                control.clear_native_instance_launch_overlay(
3617                    address.session.instance_id,
3618                );
3619            }
3620        }
3621    }
3622}
3623
3624struct SpawnPrepareRequest<'r> {
3625    instance_id: AgentInstanceId,
3626    address: &'r SessionAddress,
3627    provider: &'r AgentId,
3628    mode: SessionMode,
3629    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3630    bundle: Option<&'r ResolvedBundleReceipt>,
3631    context: Option<&'r ResolvedContextPackReceipt>,
3632    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3633    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3634    approval_level: ApprovalLevel,
3635    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3636    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3637}
3638
3639/// Guards held between "session identity minted" and "launch committed".
3640/// Field order is the drop order and matches the pre-refactor local order
3641/// (instance overlay, environment selection, materialization, harness, station lease).
3642struct PreparedSpawn<'a> {
3643    instance_overlay: Option<NativeInstanceOverlayGuard>,
3644    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3645    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3646    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3647    #[cfg(feature = "dig2-station-probe")]
3648    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3649    materialization_id: Option<MaterializationId>,
3650}
3651
3652impl PreparedSpawn<'_> {
3653    /// Session-owned launch state now belongs to the binding.
3654    fn retain_session_guards(&mut self) {
3655        if let Some(selection) = self.environment_selection.take() {
3656            selection.retain();
3657        }
3658        if let Some(overlay) = self.instance_overlay.take() {
3659            overlay.retain();
3660        }
3661        if let Some(guard) = self.materialization_guard.take() {
3662            guard.retain();
3663        }
3664    }
3665
3666    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3667    fn retain_launch_guards(&mut self) {
3668        if let Some(overlay) = self.harness_mcp_overlay.take() {
3669            overlay.retain();
3670        }
3671        #[cfg(feature = "dig2-station-probe")]
3672        if let Some(guard) = self.browser_station_lease_guard.take() {
3673            guard.retain();
3674        }
3675    }
3676}
3677
3678/// Port: everything product-side that must be prepared before a session is
3679/// bound and started (station lease, harness MCP overlay, materialization,
3680/// environment selection, network overlays, launch overlay).
3681trait SpawnPreparer {
3682    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3683    /// Select the native launch profile for an environment-profile session.
3684    fn select_environment(
3685        &self,
3686        instance_id: AgentInstanceId,
3687        provider: &AgentId,
3688        mode: SessionMode,
3689        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3690    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3691    /// Install a prepared launch overlay for the instance.
3692    fn install_launch_overlay(
3693        &self,
3694        instance_id: AgentInstanceId,
3695        overlay: PreparedNativeLaunchOverlay,
3696    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3697}
3698
3699impl NodeShared {
3700    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3701    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3702    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3703        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3704        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3705        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3706        if let Some(file) = self.session_resume_journal() {
3707            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3708        }
3709        key
3710    }
3711
3712    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3713        let accounts = self.m4a_accounts.as_ref()?;
3714        let nick = self.m4a_nick_of(instance_id)?;
3715        match accounts.session_workdir(root, provider.as_str(), &nick) {
3716            Ok(dir) => dir,
3717            Err(error) => {
3718                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3719                None
3720            }
3721        }
3722    }
3723
3724    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3725        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3726    }
3727
3728    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3729    /// `/api/spawn` returns it).
3730    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3731        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3732        for record in records.records.values_mut() {
3733            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3734                record.display_name = nick.to_owned();
3735            }
3736        }
3737    }
3738
3739    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3740    /// the mailbox listener that wakes it.
3741    fn m4a_session_overlay(
3742        &self,
3743        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3744        m4a_provider: &str,
3745        provider: &AgentId,
3746        instance_id: AgentInstanceId,
3747        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3748        session_key: &str,
3749        mode: SessionMode,
3750    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3751        let session_key = session_key.to_owned();
3752        if mode == SessionMode::Acp {
3753            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3754        }
3755        Ok(                match accounts.provision(m4a_provider, &session_key) {
3756                    Ok(account) => {
3757                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3758                        let mut identity = account.env_mutations();
3759                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3760                        let merged = match environment_overlay {
3761                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3762                                .map(PreparedNativeLaunchOverlay::Instance),
3763                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3764                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3765                            }
3766                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3767                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3768                            }
3769                        }
3770                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3771                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3772                        let mut merged = merged;
3773                        match accounts.write_session_file(&account) {
3774                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3775                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3776                                Ok(args) if !args.is_empty() => {
3777                                    merged = match merged {
3778                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3779                                            .append_extra_args(args)
3780                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3781                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3782                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3783                                            .into_instance_with_extra_args(args)
3784                                            .map(PreparedNativeLaunchOverlay::Instance)
3785                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3786                                    };
3787                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3788                                }
3789                                Ok(_) => {}
3790                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3791                            },
3792                        }
3793                        self.m4a_live
3794                            .lock()
3795                            .unwrap_or_else(|p| p.into_inner())
3796                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3797                        self.name_record_after_nick(instance_id, &account.nick);
3798                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3799                        // Letters to this account wake the session: the node listens on its mailbox.
3800                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3801                        Some(merged)
3802                    }
3803                    Err(error) => {
3804                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3805                        environment_overlay
3806                    }
3807                })
3808    }
3809}
3810
3811impl NodeShared {
3812    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3813    /// handed to the agent in session/new, with the account env on that server only.
3814    fn m4a_acp_mcp_overlay(
3815        &self,
3816        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3817        m4a_provider: &str,
3818        provider: &AgentId,
3819        instance_id: AgentInstanceId,
3820        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3821        session_key: &str,
3822    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3823        let Some(control) = self.native_launch_profile_control.as_ref() else {
3824            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3825            return Ok(environment_overlay);
3826        };
3827        let account = match accounts.provision(m4a_provider, session_key) {
3828            Ok(a) => a,
3829            Err(error) => {
3830                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3831                return Ok(environment_overlay);
3832            }
3833        };
3834        let session_file = match accounts.write_session_file(&account) {
3835            Ok(p) => p,
3836            Err(error) => {
3837                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3838                return Ok(environment_overlay);
3839            }
3840        };
3841        let env_entries: Vec<(OsString, OsString)> = account
3842            .env_mutations()
3843            .into_iter()
3844            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3845            .filter_map(|m| m.value.map(|v| (m.key, v)))
3846            .collect();
3847        let args: Vec<OsString> = vec![
3848            "--session-file".into(),
3849            session_file.into_os_string(),
3850            "mcp".into(),
3851            "--provider".into(),
3852            provider.as_str().into(),
3853        ];
3854        let overlay = NativeMcpServerLaunchOverlay::new(
3855            provider.clone(),
3856            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3857            accounts.bin().as_os_str().to_os_string(),
3858            args,
3859            env_entries,
3860            Vec::new(),
3861        );
3862        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())) {
3863            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3864            Err(error) => {
3865                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3866                return Ok(environment_overlay);
3867            }
3868        }
3869        self.m4a_live
3870            .lock()
3871            .unwrap_or_else(|p| p.into_inner())
3872            .insert(account.nick.clone(), (instance_id, provider.clone()));
3873        self.name_record_after_nick(instance_id, &account.nick);
3874        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3875        Ok(environment_overlay)
3876    }
3877}
3878
3879impl SpawnPreparer for NodeShared {
3880    fn select_environment(
3881        &self,
3882        instance_id: AgentInstanceId,
3883        provider: &AgentId,
3884        mode: SessionMode,
3885        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3886    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3887        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3888    }
3889
3890    fn install_launch_overlay(
3891        &self,
3892        instance_id: AgentInstanceId,
3893        overlay: PreparedNativeLaunchOverlay,
3894    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3895        self.install_prepared_launch_overlay(instance_id, overlay)
3896    }
3897
3898    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3899        let SpawnPrepareRequest {
3900            instance_id,
3901            address,
3902            provider,
3903            mode,
3904            environment_profile,
3905            bundle,
3906            context,
3907            managed_authority,
3908            harness_mcp,
3909            approval_level,
3910            network_allowlist,
3911            browser_profile_id,
3912        } = request;
3913        // Track A follow-on: exclusive node-local BrowserStationLease after
3914        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3915        // never ImportSession / cookies on C2. Linux/feature-off: probe
3916        // already refused or stub; this block is cfg'd out when feature off.
3917        #[cfg(feature = "dig2-station-probe")]
3918        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3919            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3920            match acquire_lease(
3921                &self.browser_station_leases,
3922                profile_id,
3923                instance_id,
3924                unix_time_ms(),
3925            ) {
3926                Ok(guard) => Some(guard),
3927                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3928                    return Err(failure(
3929                        NodeFailureCode::BrowserStationProfileBusy,
3930                        "dig2browser station profile is already leased by another session",
3931                    ));
3932                }
3933                Err(BrowserStationLeaseError::CapacityExceeded) => {
3934                    return Err(failure(
3935                        NodeFailureCode::BrowserStationProfileBusy,
3936                        "dig2browser station profile lease capacity exhausted",
3937                    ));
3938                }
3939                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3940                    return Err(failure(
3941                        NodeFailureCode::BrowserStationProfileBusy,
3942                        "session already holds a dig2browser station profile lease",
3943                    ));
3944                }
3945            }
3946        } else {
3947            None
3948        };
3949        #[cfg(not(feature = "dig2-station-probe"))]
3950        let _ = browser_profile_id;
3951        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3952            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3953            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3954            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3955            // both read the identical overlay resolved below, one as child
3956            // environment, the other translated into one stdio MCP server
3957            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3958            // only, and nothing downstream reads a harness-MCP overlay for
3959            // that transport yet.
3960            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3961            {
3962                return Err(failure(
3963                    NodeFailureCode::BindingMismatch,
3964                    "harness MCP reservation does not match the exact PTY/ACP provider",
3965                ));
3966            }
3967            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3968            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3969                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3970            })?;
3971            // Every name below is the caller's, carried in the reservation's
3972            // launch description: which environment variables receive the
3973            // endpoint, token and helper program, what the MCP server is called,
3974            // which argv it gets and which stale variables are erased.
3975            let launch = prepared.launch();
3976            let mut env_entries = vec![
3977                (
3978                    OsString::from(launch.endpoint_env.as_str()),
3979                    prepared.endpoint().as_os_str().to_os_string(),
3980                ),
3981                (
3982                    OsString::from(launch.token_env.as_str()),
3983                    prepared.token().expose().into(),
3984                ),
3985                (
3986                    OsString::from(launch.program_env.as_str()),
3987                    prepared.helper_program().as_os_str().to_os_string(),
3988                ),
3989            ];
3990            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3991                env_entries.push(trace_entry);
3992            }
3993            if let Some(door) = crate::door::door_listen_addr() {
3994                env_entries.push((
3995                    OsString::from("HATCHERY_NODE_DOOR"),
3996                    OsString::from(door.to_string()),
3997                ));
3998                // Same key shape as HQ browser/draft ids:
3999                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
4000                // slot across node boots when the instance counter resets.
4001                let session_key = format!(
4002                    "pty-{}-{}-{}",
4003                    self.incarnation_id,
4004                    address.workspace_id.as_str(),
4005                    address.session.instance_id.0
4006                );
4007                env_entries.push((
4008                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4009                    OsString::from(session_key),
4010                ));
4011            }
4012            let overlay = NativeMcpServerLaunchOverlay::new(
4013                provider.clone(),
4014                launch.server_name.as_str(),
4015                prepared.helper_program().as_os_str().to_os_string(),
4016                launch.args.iter().map(OsString::from).collect(),
4017                env_entries,
4018                launch.scrub_env.iter().map(OsString::from).collect(),
4019            ).map_err(|_| failure(
4020                NodeFailureCode::HarnessMcpUnavailable,
4021                "harness MCP launch overlay is unavailable",
4022            ))?;
4023            control
4024                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4025                .map_err(|_| failure(
4026                    NodeFailureCode::HarnessMcpUnavailable,
4027                    "harness MCP launch overlay could not be installed",
4028                ))?;
4029            // State-change line: the overlay is installed and will be read
4030            // back by the PTY child's environment or, for ACP,
4031            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4032            // time -- no secrets here, only which instance/provider/mode
4033            // now carries a harness-MCP door.
4034            tracing::info!(
4035                node_id = %self.node_id,
4036                instance_id = ?instance_id,
4037                provider = %provider,
4038                mode = ?mode,
4039                "harness MCP overlay installed for spawn",
4040            );
4041            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4042        } else {
4043            None
4044        };
4045        let prepared_materialization = self.prepare_session_materialization(
4046            &address,
4047            &provider,
4048            mode,
4049            environment_profile,
4050            bundle,
4051            context,
4052            managed_authority.cloned(),
4053        )?;
4054        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4055            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4056            None => (None, None),
4057        };
4058        let materialization_id = materialization_guard
4059            .as_ref()
4060            .and_then(SessionMaterializationGuard::id)
4061            .cloned();
4062        let mut environment_selection = SpawnPreparer::select_environment(self, 
4063            instance_id,
4064            &provider,
4065            mode,
4066            environment_profile,
4067        )?;
4068        // Station network overlays: Codex `-c network_access` and/or Claude
4069        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4070        let provider_home = materialization_guard
4071            .as_ref()
4072            .and_then(SessionMaterializationGuard::provider_home);
4073        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4074            &provider,
4075            mode,
4076            approval_level,
4077            network_allowlist,
4078            provider_home,
4079        )?;
4080        let mut network_overlay_args = network_overlay_args;
4081        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4082        {
4083            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4084            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4085            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4086                homes.extend(std::env::split_paths(&extra));
4087            }
4088            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4089            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4090            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4091        }
4092        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4093            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4094        }
4095        // Session registry: journal the session so it is resumed after a node restart.
4096        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4097            if let Some(file) = self.session_resume_journal() {
4098                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4099                    instance_id: instance_id.0,
4100                    provider: provider.as_str().to_owned(),
4101                    workspace_id: address.workspace_id.as_str().to_owned(),
4102                    provider_session_id: String::new(),
4103                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4104                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4105                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4106                    cwd: String::new(),
4107                    acp: mode == SessionMode::Acp,
4108                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4109                    cols: 0,
4110                    rows: 0,
4111                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4112                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4113                });
4114                if let Some(dir) = file.parent() {
4115                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4116                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4117                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4118                        provider: Some(provider.as_str().to_owned()),
4119                        ..crate::session_meta::Meta::default()
4120                    });
4121                    crate::session_meta::revive(dir, instance_id.0);
4122                }
4123                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4124            }
4125        }
4126        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4127            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4128        }
4129        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4130            (prepared, args) if args.is_empty() => prepared,
4131            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4132                overlay.append_extra_args(args).map_err(|_| {
4133                    failure(
4134                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135                        "network allowlist overlay could not merge with instance launch args",
4136                    )
4137                })?;
4138                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4139            }
4140            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4141                Some(PreparedNativeLaunchOverlay::Instance(
4142                    overlay
4143                        .into_instance_with_extra_args(args)
4144                        .map_err(|_| {
4145                            failure(
4146                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4147                                "network allowlist overlay could not merge with environment overlay",
4148                            )
4149                        })?,
4150                ))
4151            }
4152            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4153                NativeInstanceLaunchOverlay::new(
4154                    provider.clone(),
4155                    match mode {
4156                        SessionMode::Pty => TransportKind::Pty,
4157                        SessionMode::Acp => TransportKind::Acp,
4158                        SessionMode::Inline => TransportKind::Pipe,
4159                    },
4160                    Vec::new(),
4161                    args,
4162                )
4163                .map_err(|_| {
4164                    failure(
4165                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4166                        "network allowlist overlay is invalid for this transport",
4167                    )
4168                })?,
4169            )),
4170        };
4171        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4172        // overlay or appended to the overlay the spawn already carries.
4173        #[cfg(unix)]
4174        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4175        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4176            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4177        });
4178        #[cfg(unix)]
4179        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4180        // signer handle is for sessions without one (operator shells).
4181        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4182        #[cfg(unix)]
4183        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4184            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4185                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4186                let uid = unsafe { libc_geteuid() };
4187                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4188                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4189                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4190                Some(vec![
4191                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4192                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4193                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4194                ])
4195            }
4196            _ => None,
4197        };
4198        #[cfg(not(unix))]
4199        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4200        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4201            (SessionMode::Pty, _, _, Some(identity)) => {
4202                let merged = match environment_overlay {
4203                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4204                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4205                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4206                    }
4207                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4208                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4209                    }
4210                }
4211                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4212                Some(merged)
4213            }
4214            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4215                let session_key = self.m4a_session_key(instance_id);
4216                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4217            }
4218            (SessionMode::Pty, Some(_), None, _) => {
4219                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4220                environment_overlay
4221            }
4222            _ => environment_overlay,
4223        };
4224        let mut instance_overlay = environment_overlay
4225            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4226            .transpose()?
4227            .flatten();
4228        Ok(PreparedSpawn {
4229            instance_overlay,
4230            environment_selection,
4231            materialization_guard,
4232            harness_mcp_overlay,
4233            #[cfg(feature = "dig2-station-probe")]
4234            browser_station_lease_guard,
4235            materialization_id,
4236        })
4237    }
4238}
4239
4240struct SessionRecords {
4241    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4242}
4243
4244struct SpawnIdempotencyCache {
4245    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4246}
4247
4248struct SpawnIdempotencyEntry {
4249    value: SpawnIdempotencyValue,
4250    expires_at: Instant,
4251}
4252
4253enum SpawnIdempotencyValue {
4254    Standard {
4255        spec: SpawnSpec,
4256        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4257    },
4258    Managed {
4259        request: ManagedWorktreeSpawnRequest,
4260        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4261    },
4262    HarnessMcp,
4263}
4264
4265enum DurableManagedSpawnReplayDecision {
4266    Reserved,
4267    PendingLinked,
4268    Committed(ManagedWorktreeSpawnReceipt),
4269}
4270
4271impl SpawnIdempotencyValue {
4272    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4273        match self {
4274            Self::Standard { result, .. } => result
4275                .as_ref()
4276                .ok()
4277                .and_then(|receipt| receipt.context.as_ref()),
4278            Self::Managed { result, .. } => result
4279                .as_ref()
4280                .ok()
4281                .and_then(|receipt| receipt.spawn.context.as_ref()),
4282            Self::HarnessMcp => None,
4283        }
4284        .is_some_and(|receipt| &receipt.id == context_id)
4285    }
4286}
4287
4288#[derive(Clone, Copy)]
4289struct ControllerLease {
4290    connection_id: u64,
4291    expires_at: Instant,
4292}
4293
4294struct NodeEventHistory {
4295    last_sequence: u64,
4296    replay_floor_sequence: u64,
4297    events: VecDeque<NodeEventEnvelope>,
4298    record_providers: BTreeMap<SessionRecordId, AgentId>,
4299    removed_record_providers: BTreeMap<u64, AgentId>,
4300}
4301
4302impl NodeEventHistory {
4303    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4304        Self {
4305            last_sequence: 0,
4306            replay_floor_sequence: 1,
4307            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4308            record_providers,
4309            removed_record_providers: BTreeMap::new(),
4310        }
4311    }
4312}
4313
4314impl NodeShared {
4315    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4316    /// as a descendant of this session's process (with the session's own environment) and
4317    /// never attaches to an operator's shared leader. Same grok home, same login.
4318    fn session_resume_journal(&self) -> Option<PathBuf> {
4319        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4320    }
4321
4322    /// Node start: resume every journalled session through its provider's resume path,
4323    /// under its old instance number, so HQ shows the same chat.
4324    async fn restore_journalled_sessions(&self) {
4325        let Some(file) = self.session_resume_journal() else { return };
4326        if !crate::pty_resume::enabled() { return; }
4327        for e in crate::pty_resume::load(&file) {
4328            crate::pty_resume::remove(&file, e.instance_id);
4329            // Same m4a session key as before the restart: the session keeps its account and nick.
4330            if !e.session_key.is_empty() {
4331                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4332            }
4333            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4334            if e.acp {
4335                let record_id = {
4336                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4337                    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())
4338                };
4339                let cur = self.next_instance_id.load(Ordering::Acquire);
4340                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4341                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4342                let resumed = match record_id {
4343                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4344                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4345                };
4346                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4347                let r = match resumed {
4348                    Ok(a) => Ok(a),
4349                    Err(err) => {
4350                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4351                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4352                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4353                    }
4354                };
4355                match r {
4356                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4357                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4358                }
4359                continue;
4360            }
4361            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4362                // Nothing said yet in that chat: start it fresh under the same number.
4363                let cur = self.next_instance_id.load(Ordering::Acquire);
4364                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4365                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4366                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4367                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4368                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4369                }
4370                continue;
4371            }
4372            let folder = std::path::Path::new(&e.cwd);
4373            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4374            let name = match (in_session_dir, folder.file_name()) {
4375                (true, Some(n)) => n.to_string_lossy().into_owned(),
4376                _ => format!("{} #{}", e.provider, e.instance_id),
4377            };
4378            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4379            let record = match self.index_provider_session(ws, provider, identity, name) {
4380                Ok(r) => r,
4381                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4382            };
4383            let cur = self.next_instance_id.load(Ordering::Acquire);
4384            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4385            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4386            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4387            match r {
4388                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4389                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4390            }
4391        }
4392    }
4393
4394    fn refresh_session_journal(&self) {
4395        let Some(file) = self.session_resume_journal() else { return };
4396        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4397        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4398        crate::pty_resume::refresh(&file, &homes);
4399        let ids: Vec<(u64, String)> = {
4400            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4401            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4402        };
4403        for (inst, sid) in ids {
4404            crate::pty_resume::set_session_id(&file, inst, &sid);
4405        }
4406    }
4407
4408    fn session_grok_leader_socket(
4409        &self,
4410        provider: &AgentId,
4411        mode: SessionMode,
4412        instance_id: AgentInstanceId,
4413    ) -> Option<std::path::PathBuf> {
4414        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4415            return None;
4416        }
4417        let root = self
4418            .m4a_accounts
4419            .as_ref()
4420            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4421            .unwrap_or_else(std::env::temp_dir);
4422        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4423        if let Some(previous) = self
4424            .grok_leaders
4425            .lock()
4426            .unwrap_or_else(|p| p.into_inner())
4427            .insert(instance_id, socket.clone())
4428        {
4429            crate::grok_leader::retire(&previous);
4430        }
4431        Some(socket)
4432    }
4433
4434    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4435    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4436        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4437        if let Some(socket) = socket {
4438            crate::grok_leader::retire(&socket);
4439        }
4440    }
4441
4442    fn new_with_incarnation(
4443        handle: Gate4AgentHandle,
4444        access_token: String,
4445        node_id: NodeId,
4446        incarnation_id: NodeIncarnationId,
4447        workspaces: Vec<WorkspaceConfig>,
4448        enabled_providers: Vec<AgentId>,
4449        provider_runtime_statuses: ProviderRuntimeStatuses,
4450        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4451        provider_contracts: Vec<ProviderContractSupport>,
4452        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4453        spawn_profiles: SpawnProfileRegistry,
4454        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4455        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4456        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4457        state_path: Option<PathBuf>,
4458        records: Vec<ManagedSessionRecord>,
4459        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4460        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4461        materializations: Vec<MaterializationOwnershipRecord>,
4462        persistence_warning: Option<String>,
4463        input_settle_timeout_ms: u64,
4464    ) -> Self {
4465        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4466        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4467        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4468        let (harness_mcp_proxy_event_tx, _) =
4469            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4470        let record_providers = records
4471            .iter()
4472            .map(|record| (record.record_id.clone(), record.provider.clone()))
4473            .collect();
4474        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4475            .iter()
4476            .map(|workspace| {
4477                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4478            })
4479            .collect();
4480        for lease in &managed_worktrees {
4481            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4482        }
4483        let managed_worktree_profiles = workspaces.iter()
4484            .map(|workspace| (
4485                workspace.workspace_id.clone(),
4486                workspace.managed_worktree_profiles.clone(),
4487            ))
4488            .collect();
4489        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4490            managed_worktrees,
4491            managed_worktree_tombstones,
4492        ).expect("durable managed worktree registry was validated while loading");
4493        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4494        managed_worktrees.reattach_record_holders(
4495            &managed_worktree_record_holders(&records),
4496            unix_time_ms(),
4497        );
4498        Self {
4499            handle,
4500            access_token,
4501            node_id,
4502            incarnation_id,
4503            started_at_unix_ms: std::time::SystemTime::now()
4504                .duration_since(std::time::UNIX_EPOCH)
4505                .unwrap_or_default()
4506                .as_millis()
4507                .min(u64::MAX as u128) as u64,
4508            workspaces: RwLock::new(
4509                workspaces
4510                    .into_iter()
4511                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4512                    .collect(),
4513            ),
4514            worktree_service_modes,
4515            managed_worktree_profiles,
4516            managed_worktrees: Mutex::new(managed_worktrees),
4517            enabled_providers,
4518            provider_runtime_statuses,
4519            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4520            provider_runtime_monitor,
4521            provider_contracts,
4522            provider_adapter_contracts,
4523            spawn_profiles,
4524            environment_profiles: RwLock::new(BTreeMap::new()),
4525            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4526            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4527            #[cfg(feature = "dig2-station-probe")]
4528            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4529            bundle_catalog: RwLock::new(BundleCatalog::default()),
4530            delivery_store: Mutex::new(None),
4531            context_pack_store: Mutex::new(None),
4532            context_catalog: RwLock::new(ContextPackCatalog::default()),
4533            native_launch_profile_control,
4534            harness_mcp_registry: None,
4535            native_session_catalog,
4536            session_environment_materializer,
4537            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4538            m4a_live: Mutex::new(BTreeMap::new()),
4539            m4a_keys: Mutex::new(BTreeMap::new()),
4540            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4541            grok_leaders: Mutex::new(BTreeMap::new()),
4542            #[cfg(unix)]
4543            #[cfg(unix)]
4544            signer_proxy: std::sync::OnceLock::new(),
4545            materializations: Mutex::new(
4546                materializations
4547                    .into_iter()
4548                    .map(|record| (record.id().clone(), record))
4549                    .collect(),
4550            ),
4551            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4552                entries: BTreeMap::new(),
4553            }),
4554            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4555            controller: Mutex::new(None),
4556            provisional_spawns: Mutex::new(BTreeMap::new()),
4557            pending_acp_options: Mutex::new(BTreeMap::new()),
4558            history: Mutex::new(NodeEventHistory::new(record_providers)),
4559            event_tx,
4560            terminal_event_tx,
4561            agent_stream_event_tx,
4562            harness_mcp_proxy_event_tx,
4563            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4564            pty_input_wake: tokio::sync::Notify::new(),
4565            pty_input_marks: Mutex::new(BTreeMap::new()),
4566            provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4567            next_connection_id: AtomicU64::new(1),
4568            next_instance_id: AtomicU64::new(1),
4569            next_command_id: AtomicU64::new(1),
4570            shutdown: AtomicBool::new(false),
4571            shutdown_notify: Notify::new(),
4572            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4573            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4574            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4575            mutation_gate: AsyncMutex::new(()),
4576            session_bindings: Mutex::new(BTreeMap::new()),
4577            session_records: Mutex::new(SessionRecords {
4578                records: records
4579                    .into_iter()
4580                    .map(|record| (record.record_id.clone(), record))
4581                    .collect(),
4582            }),
4583            state_path,
4584            persistence_error: RwLock::new(
4585                persistence_warning
4586                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4587            ),
4588            state_transaction: Mutex::new(()),
4589            input_settle_timeout_ms,
4590            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4591            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4592            shell_efficiency_profile: OnceLock::new(),
4593            native_session_gauge: AtomicUsize::new(0),
4594            connection_loop_iterations: AtomicU64::new(0),
4595            connection_events_sent: AtomicU64::new(0),
4596            rejected_commands_total: AtomicU64::new(0),
4597            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4598            drive_loop_iterations_total: AtomicU64::new(0),
4599            drive_loop_iterations_idle: AtomicU64::new(0),
4600            session_record_retention: SessionRecordRetentionConfig::default(),
4601            retired_records_total: AtomicU64::new(0),
4602            #[cfg(feature = "fixture")]
4603            fixture_semantic_hook_policy: false,
4604            #[cfg(feature = "fixture")]
4605            fixture_spawn_managed_worktree_v2_failure_probe:
4606                SpawnManagedWorktreeV2FailureProbe::default(),
4607        }
4608    }
4609
4610    #[cfg(test)]
4611    fn new(
4612        handle: Gate4AgentHandle,
4613        access_token: String,
4614        node_id: NodeId,
4615        workspaces: Vec<WorkspaceConfig>,
4616        enabled_providers: Vec<AgentId>,
4617    ) -> Self {
4618        Self::new_with_incarnation(
4619            handle,
4620            access_token,
4621            node_id,
4622            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4623            workspaces,
4624            enabled_providers,
4625            ProviderRuntimeStatuses::default(),
4626            None,
4627            Vec::new(),
4628            Vec::new(),
4629            SpawnProfileRegistry::default(),
4630            None,
4631            None,
4632            None,
4633            None,
4634            Vec::new(),
4635            Vec::new(),
4636            Vec::new(),
4637            Vec::new(),
4638            None,
4639            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4640        )
4641    }
4642
4643    fn persistence_error(&self) -> Option<String> {
4644        self.persistence_error
4645            .read()
4646            .unwrap_or_else(|poisoned| poisoned.into_inner())
4647            .clone()
4648    }
4649
4650    async fn admit_provider_runtime(
4651        &self,
4652        provider: &AgentId,
4653        requirement: ProviderRuntimeRequirement,
4654    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4655        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4656            let pipe_transport = monitor.supports_pipe_transport(provider);
4657            let acp_transport = monitor.supports_acp_transport(provider);
4658            let provider = provider.clone();
4659            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4660            let (status, admission) = timeout(
4661                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4662                refresh,
4663            )
4664            .await
4665            .map_err(|_| failure(
4666                NodeFailureCode::BackendBusy,
4667                "provider runtime probe exceeded its bounded deadline",
4668            ))?
4669            .map_err(|_| failure(
4670                NodeFailureCode::BackendOperationFailed,
4671                "provider runtime probe task failed",
4672            ))?;
4673            if let Some(status) = status {
4674                self
4675                    .provider_runtime_status_updates
4676                    .write()
4677                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4678                    .insert(status.provider().clone(), status);
4679            }
4680            admission.and_then(|policy| {
4681                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4682            })
4683        } else {
4684            // No live monitor means no catalog to consult -- there is
4685            // nothing here to confirm this provider declares a Pipe or ACP
4686            // transport, so Inline/Acp are rejected the same conservative
4687            // way an undeclared provider always was, rather than admitted
4688            // on trust.
4689            crate::provider_runtime::admit_status(
4690                &self.provider_runtime_statuses,
4691                provider,
4692                false,
4693                false,
4694                requirement,
4695            )
4696        };
4697        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4698        admission.map_err(|error| match error {
4699            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4700                NodeFailureCode::BackendOperationFailed,
4701                "provider launcher is unavailable",
4702            ),
4703            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4704                NodeFailureCode::UnsupportedCapability,
4705                "provider semantic capability is not verified",
4706            ),
4707            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4708                NodeFailureCode::BackendBusy,
4709                "provider runtime probe is already in progress",
4710            ),
4711        })
4712    }
4713
4714    /// Whether the catalog declares a Pipe transport for `provider` --
4715    /// the fact `require_policy` needs to admit or reject
4716    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4717    /// catalog to consult, so this stays conservative and rejects, matching
4718    /// `admit_provider_runtime`'s own no-monitor branch.
4719    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4720        match self.provider_runtime_monitor.as_deref() {
4721            Some(monitor) => monitor.supports_pipe_transport(provider),
4722            None => false,
4723        }
4724    }
4725
4726    /// Whether the catalog declares an ACP transport for `provider` -- the
4727    /// fact `require_policy` needs to admit or reject
4728    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4729    /// `provider_supports_pipe_transport` exactly, including the
4730    /// conservative no-monitor default.
4731    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4732        match self.provider_runtime_monitor.as_deref() {
4733            Some(monitor) => monitor.supports_acp_transport(provider),
4734            None => false,
4735        }
4736    }
4737
4738    /// Grants hook ingestion to a provider whose catalog entry declares a
4739    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4740    ///
4741    /// `admit_status`'s `RawPassthrough` arm hands back
4742    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4743    /// correct for everything it was written to answer, and wrong for hooks
4744    /// specifically. A hook event is not inferred from terminal behaviour,
4745    /// so it has nothing to do with whether a launcher probe verified this
4746    /// CLI version's PTY contract; the authority is the catalog declaring
4747    /// the adapter plus the ingress authenticating the route. Without this
4748    /// the node installs a provider's hooks, the CLI faithfully calls them,
4749    /// and every event is refused -- which is exactly the state grok was in.
4750    ///
4751    /// Applied to both admission paths so the monitor's own derivation and
4752    /// this coarse fallback cannot disagree about the same provider.
4753    fn admit_declared_hook_semantics(
4754        &self,
4755        provider: &AgentId,
4756        policy: ProviderRuntimePolicy,
4757    ) -> ProviderRuntimePolicy {
4758        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4759            return policy;
4760        }
4761        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4762            contract.provider == *provider && contract.family == AdapterFamily::Hook
4763        });
4764        if !declares_hook {
4765            return policy;
4766        }
4767        ProviderRuntimePolicy::new(
4768            policy.raw_pty_lifecycle,
4769            policy.semantic_readiness,
4770            policy.structured_prompt,
4771            policy.provider_session_identity,
4772            policy.semantic_resume,
4773            true,
4774        )
4775        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4776    }
4777
4778    /// Station network allowlist → provider launch overlay argv.
4779    ///
4780    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4781    ///   `provider_native` under Moderate (PTY or ACP).
4782    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4783    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4784    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4785    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4786    ///   no overlay.
4787    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4788    ///   empty overlay when no `provider_native`; invented native keys already
4789    ///   refuse at load / resolve.
4790    ///
4791    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4792    fn resolve_network_allowlist_overlay_args(
4793        &self,
4794        provider: &AgentId,
4795        mode: SessionMode,
4796        approval_level: ApprovalLevel,
4797        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4798        provider_home: Option<&Path>,
4799    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4800        let Some(allowlist_id) = network_allowlist else {
4801            return Ok(Vec::new());
4802        };
4803        let catalog = self
4804            .network_allowlist_catalog
4805            .read()
4806            .unwrap_or_else(|poisoned| poisoned.into_inner());
4807        let Some(entry) = catalog.get(allowlist_id) else {
4808            return Err(failure(
4809                NodeFailureCode::UnknownNetworkAllowlist,
4810                "spawn network allowlist is unavailable on this node",
4811            ));
4812        };
4813
4814        // Claude: settings-shaped overlay from permits (not provider_native).
4815        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4816            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4817                |message| {
4818                    failure(
4819                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820                        &message,
4821                    )
4822                },
4823            )?;
4824            if !matches!(mode, SessionMode::Pty) {
4825                return Err(failure(
4826                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4827                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4828                ));
4829            }
4830            let Some(home) = provider_home else {
4831                return Err(failure(
4832                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4833                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4834                ));
4835            };
4836            let domains =
4837                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4838            if domains.is_empty() {
4839                return Err(failure(
4840                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4841                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4842                ));
4843            }
4844            let settings_path = home.join(
4845                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4846            );
4847            let json =
4848                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4849            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4850                |_| {
4851                    failure(
4852                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853                        "Claude station network settings sidecar could not be written under ProviderHome",
4854                    )
4855                },
4856            )?;
4857            let overlay =
4858                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4859            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4860        }
4861
4862        let Some(native) = entry.provider_native.as_ref() else {
4863            return Ok(Vec::new());
4864        };
4865        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4866            provider.as_str(),
4867            approval_level,
4868            native,
4869        )
4870        .map_err(|message| {
4871            failure(
4872                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4873                &message,
4874            )
4875        })?;
4876        if overlay.is_empty() {
4877            return Ok(Vec::new());
4878        }
4879        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4880            return Err(failure(
4881                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4882                "Codex network_access -c overlay requires PTY or ACP transport",
4883            ));
4884        }
4885        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4886    }
4887
4888    /// Backward-compatible alias used by Codex-focused unit tests.
4889    fn resolve_codex_network_access_overlay_args(
4890        &self,
4891        provider: &AgentId,
4892        mode: SessionMode,
4893        approval_level: ApprovalLevel,
4894        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4895    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4896        self.resolve_network_allowlist_overlay_args(
4897            provider,
4898            mode,
4899            approval_level,
4900            network_allowlist,
4901            None,
4902        )
4903    }
4904
4905    fn resolve_environment_profile(
4906        &self,
4907        resolved: &ResolvedSpawnSpec,
4908    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4909        // Station network allowlist catalog enforce (plan
4910        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4911        // catalog — unknown ids refuse rather than silent ambient. Runs even
4912        // when no environment_profile_id is present so a bare allowlist still
4913        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4914        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4915            let catalog = self
4916                .network_allowlist_catalog
4917                .read()
4918                .unwrap_or_else(|poisoned| poisoned.into_inner());
4919            let Some(entry) = catalog.get(allowlist_id) else {
4920                return Err(failure(
4921                    NodeFailureCode::UnknownNetworkAllowlist,
4922                    "spawn network allowlist is unavailable on this node",
4923                ));
4924            };
4925            // Track B: refuse provider-native mappings this provider cannot
4926            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4927            if let Some(native) = entry.provider_native.as_ref() {
4928                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4929                    resolved.provider.as_str(),
4930                    resolved.approval_level,
4931                    native,
4932                ) {
4933                    return Err(failure(
4934                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4935                        &message,
4936                    ));
4937                }
4938            }
4939        }
4940        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4941        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4942        // cheap named-pipe path reachability must succeed (Windows
4943        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4944        // NodeFailureCode. Runs even without environment_profile_id (same
4945        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4946        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4947        // acquired later in spawn_session_with_deadline (after instance id).
4948        #[cfg(feature = "dig2-station-probe")]
4949        if resolved.browser_profile_id.is_some() {
4950            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4951            match probe_station_reachable() {
4952                Ok(()) => {}
4953                Err(StationProbeError::UnsupportedPlatform)
4954                | Err(StationProbeError::InvalidPipeSuffix) => {
4955                    return Err(failure(
4956                        NodeFailureCode::BrowserStationProbeUnavailable,
4957                        "dig2browser station probe unavailable on this platform",
4958                    ));
4959                }
4960                Err(StationProbeError::Unreachable) => {
4961                    return Err(failure(
4962                        NodeFailureCode::BrowserStationUnreachable,
4963                        "dig2browser station named-pipe is unreachable",
4964                    ));
4965                }
4966            }
4967        }
4968        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4969            return Ok(None);
4970        };
4971        let profiles = self
4972            .environment_profiles
4973            .read()
4974            .unwrap_or_else(|poisoned| poisoned.into_inner());
4975        let binding = profiles.get(profile_id).ok_or_else(|| {
4976            failure(
4977                NodeFailureCode::UnknownEnvironmentProfile,
4978                "spawn environment profile is unavailable on this node",
4979            )
4980        })?;
4981        if binding.provider != resolved.provider
4982            || binding.native_profile_id(resolved.mode).is_none()
4983        {
4984            return Err(failure(
4985                NodeFailureCode::EnvironmentProfileBindingMismatch,
4986                "spawn environment profile does not match the provider and mode",
4987            ));
4988        }
4989        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4990        // after catalog + optional dig2 probe above. Empty/whitespace ids
4991        // already refuse at Spawn*Id construction.
4992        Ok(Some(ResolvedEnvironmentProfileReceipt {
4993            profile_id: binding.id.clone(),
4994            profile_revision: binding.revision.clone(),
4995            network_allowlist: resolved.network_allowlist.clone(),
4996            browser_profile_id: resolved.browser_profile_id.clone(),
4997        }))
4998    }
4999
5000    fn resolve_bundle(
5001        &self,
5002        resolved: &ResolvedSpawnSpec,
5003        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5004    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5005        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5006            return Ok(None);
5007        };
5008        let catalog = self.bundle_catalog
5009            .read()
5010            .unwrap_or_else(|poisoned| poisoned.into_inner());
5011        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5012                failure(
5013                    NodeFailureCode::UnknownBundle,
5014                    "spawn bundle is unavailable on this node",
5015                )
5016            })?;
5017        self.bundle_layout(
5018            &resolved.provider,
5019            resolved.mode,
5020            environment_profile,
5021            bundle,
5022        )?;
5023        Ok(Some(bundle.receipt()))
5024    }
5025
5026    fn begin_delivery_stage(
5027        &self,
5028        manifest: DeliveryBundleManifestV2,
5029    ) -> Result<NodeResponse, NodeFailure> {
5030        let mut store = self
5031            .delivery_store
5032            .lock()
5033            .unwrap_or_else(|poisoned| poisoned.into_inner());
5034        let store = store.as_mut().ok_or_else(|| {
5035            failure(
5036                NodeFailureCode::DeliveryStageStorageFailed,
5037                "node delivery store is unavailable",
5038            )
5039        })?;
5040        let begun = store.begin(manifest).map_err(delivery_failure)?;
5041        Ok(NodeResponse::DeliveryStageBegun {
5042            stage_id: begun.stage_id,
5043            manifest_digest: begun.manifest_digest,
5044            missing_blobs: begun.missing_blobs,
5045        })
5046    }
5047
5048    fn put_delivery_blob_chunk(
5049        &self,
5050        stage_id: DeliveryStageId,
5051        blob_digest: DeliveryBlobDigestV1,
5052        offset: u64,
5053        chunk_hex: DeliveryBlobChunkHexV1,
5054    ) -> Result<NodeResponse, NodeFailure> {
5055        let mut store = self
5056            .delivery_store
5057            .lock()
5058            .unwrap_or_else(|poisoned| poisoned.into_inner());
5059        let store = store.as_mut().ok_or_else(|| {
5060            failure(
5061                NodeFailureCode::DeliveryStageStorageFailed,
5062                "node delivery store is unavailable",
5063            )
5064        })?;
5065        let accepted = store
5066            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5067            .map_err(delivery_failure)?;
5068        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5069            stage_id: accepted.stage_id,
5070            blob_digest: accepted.blob_digest,
5071            next_offset: accepted.next_offset,
5072        })
5073    }
5074
5075    fn commit_delivery_stage(
5076        &self,
5077        stage_id: DeliveryStageId,
5078    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5079        let mut store = self
5080            .delivery_store
5081            .lock()
5082            .unwrap_or_else(|poisoned| poisoned.into_inner());
5083        let store = store.as_mut().ok_or_else(|| {
5084            failure(
5085                NodeFailureCode::DeliveryStageStorageFailed,
5086                "node delivery store is unavailable",
5087            )
5088        })?;
5089        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5090        let mut catalog = self
5091            .bundle_catalog
5092            .write()
5093            .unwrap_or_else(|poisoned| poisoned.into_inner());
5094        let mut next_catalog = catalog.clone();
5095        next_catalog
5096            .insert_idempotent(prepared.bundle().clone())
5097            .map_err(|_| {
5098                failure(
5099                    NodeFailureCode::DeliveryStageConflict,
5100                    "delivery bundle identity conflicts with installed content",
5101                )
5102            })?;
5103        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5104        *catalog = next_catalog;
5105        Ok(receipt)
5106    }
5107
5108    fn abort_delivery_stage(
5109        &self,
5110        stage_id: &DeliveryStageId,
5111    ) -> Result<(), NodeFailure> {
5112        let mut store = self
5113            .delivery_store
5114            .lock()
5115            .unwrap_or_else(|poisoned| poisoned.into_inner());
5116        let store = store.as_mut().ok_or_else(|| {
5117            failure(
5118                NodeFailureCode::DeliveryStageStorageFailed,
5119                "node delivery store is unavailable",
5120            )
5121        })?;
5122        store.abort(stage_id).map_err(delivery_failure)
5123    }
5124
5125    fn resolve_context(
5126        &self,
5127        resolved: &ResolvedSpawnSpec,
5128    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5129        let Some(context_id) = resolved.context_id.as_ref() else {
5130            return Ok(None);
5131        };
5132        self.context_catalog
5133            .read()
5134            .unwrap_or_else(|poisoned| poisoned.into_inner())
5135            .get(context_id)
5136            .map(|context| context.receipt().clone())
5137            .ok_or_else(|| {
5138                failure(
5139                    NodeFailureCode::UnknownContextPack,
5140                    "spawn context pack is unavailable on this node",
5141                )
5142            })
5143            .map(Some)
5144    }
5145
5146    fn bundle_layout(
5147        &self,
5148        provider: &AgentId,
5149        mode: SessionMode,
5150        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5151        bundle: &NodeBundle,
5152    ) -> Result<BundleProviderLayout, NodeFailure> {
5153        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5154            failure(
5155                NodeFailureCode::BundleBindingMismatch,
5156                "spawn bundle does not match the provider and mode",
5157            )
5158        })?;
5159        if layout != BundleProviderLayout::Codex {
5160            return Ok(layout);
5161        }
5162        let profile = environment_profile.and_then(|receipt| {
5163            let profiles = self
5164                .environment_profiles
5165                .read()
5166                .unwrap_or_else(|poisoned| poisoned.into_inner());
5167            let binding = profiles.get(&receipt.profile_id)?;
5168            if binding.revision != receipt.profile_revision
5169                || &binding.provider != provider
5170                || binding.native_profile_id(mode).is_none()
5171            {
5172                return None;
5173            }
5174            self.environment_materialization_profiles
5175                .read()
5176                .unwrap_or_else(|poisoned| poisoned.into_inner())
5177                .get(&receipt.profile_id)
5178                .cloned()
5179        });
5180        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5181            return Err(failure(
5182                NodeFailureCode::BundleBindingMismatch,
5183                "Codex bundle requires an exact isolated CODEX_HOME profile",
5184            ));
5185        }
5186        Ok(layout)
5187    }
5188
5189    fn select_environment_profile(
5190        &self,
5191        instance_id: AgentInstanceId,
5192        provider: &AgentId,
5193        mode: SessionMode,
5194        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5195    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5196        let Some(environment_profile) = environment_profile else {
5197            return Ok(None);
5198        };
5199        let native_profile_id = {
5200            let profiles = self
5201                .environment_profiles
5202                .read()
5203                .unwrap_or_else(|poisoned| poisoned.into_inner());
5204            let binding = profiles
5205                .get(&environment_profile.profile_id)
5206                .ok_or_else(|| {
5207                    failure(
5208                        NodeFailureCode::UnknownEnvironmentProfile,
5209                        "session environment profile is unavailable on this node",
5210                    )
5211                })?;
5212            if &binding.revision != &environment_profile.profile_revision
5213                || &binding.provider != provider
5214            {
5215                return Err(failure(
5216                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5217                    "session environment profile revision or provider changed",
5218                ));
5219            }
5220            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5221                failure(
5222                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5223                    "session environment profile does not support this mode",
5224                )
5225            })?
5226        };
5227        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5228            failure(
5229                NodeFailureCode::BackendOperationFailed,
5230                "native environment profile controller is unavailable",
5231            )
5232        })?;
5233        control
5234            .select_native_launch_profile(instance_id, native_profile_id)
5235            .map_err(|_| {
5236                failure(
5237                    NodeFailureCode::BackendOperationFailed,
5238                    "native environment profile selection failed",
5239                )
5240            })?;
5241        Ok(Some(NativeEnvironmentSelectionGuard {
5242            control: Some(control),
5243            instance_id,
5244        }))
5245    }
5246
5247    fn install_prepared_launch_overlay(
5248        &self,
5249        instance_id: AgentInstanceId,
5250        overlay: PreparedNativeLaunchOverlay,
5251    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5252        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5253            failure(
5254                NodeFailureCode::BackendOperationFailed,
5255                "native launch profile controller is unavailable",
5256            )
5257        })?;
5258        match overlay {
5259            PreparedNativeLaunchOverlay::Environment(overlay) => {
5260                control
5261                    .install_native_launch_environment_overlay(instance_id, overlay)
5262                    .map_err(|_| failure(
5263                        NodeFailureCode::BackendOperationFailed,
5264                        "session environment overlay installation failed",
5265                    ))?;
5266                Ok(None)
5267            }
5268            PreparedNativeLaunchOverlay::Instance(overlay) => {
5269                control
5270                    .install_native_instance_launch_overlay(instance_id, overlay)
5271                    .map_err(|_| failure(
5272                        NodeFailureCode::BundleBindingMismatch,
5273                        "session bundle launch overlay installation failed",
5274                    ))?;
5275                Ok(Some(NativeInstanceOverlayGuard {
5276                    control: Some(control),
5277                    instance_id,
5278                }))
5279            }
5280        }
5281    }
5282
5283    fn environment_materialization_profile(
5284        &self,
5285        environment_profile: &ResolvedEnvironmentProfileReceipt,
5286    ) -> Option<NodeSessionMaterializationProfile> {
5287        self.environment_materialization_profiles
5288            .read()
5289            .unwrap_or_else(|poisoned| poisoned.into_inner())
5290            .get(&environment_profile.profile_id)
5291            .cloned()
5292    }
5293
5294    fn materialization_profile(
5295        &self,
5296        provider: &AgentId,
5297        mode: SessionMode,
5298        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5299        bundle: Option<&ResolvedBundleReceipt>,
5300        context: Option<&ResolvedContextPackReceipt>,
5301    ) -> Result<Option<(
5302        NodeSessionMaterializationProfile,
5303        bool,
5304        Option<BundleProviderLayout>,
5305    )>, NodeFailure> {
5306        let environment = environment_profile
5307            .and_then(|receipt| self.environment_materialization_profile(receipt));
5308        let (bundle, bundle_layout) = match bundle {
5309            Some(receipt) => {
5310                let catalog = self.bundle_catalog.read()
5311                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5312                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5313                    NodeFailureCode::UnknownBundle,
5314                    "session bundle is unavailable on this node",
5315                ))?;
5316                if installed.receipt() != *receipt {
5317                    return Err(failure(
5318                        NodeFailureCode::BundleBindingMismatch,
5319                        "session bundle revision or digest changed",
5320                    ));
5321                }
5322                let layout = self.bundle_layout(
5323                    provider,
5324                    mode,
5325                    environment_profile,
5326                    installed,
5327                )?;
5328                (Some(installed.clone()), Some(layout))
5329            }
5330            None => (None, None),
5331        };
5332        let has_environment_overlay = environment.is_some() || context.is_some();
5333        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5334            (None, None, None) => None,
5335            (Some(profile), None, None) => Some(Ok(profile)),
5336            (None, Some(bundle), Some(layout)) => {
5337                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5338            }
5339            (Some(profile), Some(bundle), Some(layout)) => {
5340                Some(profile.with_bundle(bundle, layout))
5341            }
5342            _ => {
5343                return Err(failure(
5344                    NodeFailureCode::BundleBindingMismatch,
5345                    "session bundle provider layout is unavailable",
5346                ));
5347            }
5348        };
5349        let profile = profile
5350            .transpose()
5351            .map_err(|_| failure(
5352                NodeFailureCode::BundleMaterializationFailed,
5353                "session bundle materialization profile is invalid",
5354            ))?;
5355        let profile = match (profile, context) {
5356            (Some(profile), Some(receipt)) => {
5357                let catalog = self.context_catalog
5358                    .read()
5359                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5360                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5361                    NodeFailureCode::UnknownContextPack,
5362                    "session context pack is unavailable on this node",
5363                ))?;
5364                if pack.receipt() != receipt {
5365                    return Err(failure(
5366                        NodeFailureCode::ContextPackMaterializationFailed,
5367                        "session context pack identity changed",
5368                    ));
5369                }
5370                profile.with_context(pack)
5371            }
5372            (None, Some(receipt)) => {
5373                let catalog = self.context_catalog
5374                    .read()
5375                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5376                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5377                    NodeFailureCode::UnknownContextPack,
5378                    "session context pack is unavailable on this node",
5379                ))?;
5380                if pack.receipt() != receipt {
5381                    return Err(failure(
5382                        NodeFailureCode::ContextPackMaterializationFailed,
5383                        "session context pack identity changed",
5384                    ));
5385                }
5386                NodeSessionMaterializationProfile::from_context(pack)
5387            }
5388            (Some(profile), None) => Ok(profile),
5389            (None, None) => return Ok(None),
5390        }
5391        .map_err(|_| failure(
5392            NodeFailureCode::ContextPackMaterializationFailed,
5393            "session context pack materialization profile is invalid",
5394        ))?;
5395        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5396    }
5397
5398    fn prepare_session_materialization<'a>(
5399        &'a self,
5400        address: &SessionAddress,
5401        provider: &AgentId,
5402        mode: SessionMode,
5403        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5404        bundle: Option<&ResolvedBundleReceipt>,
5405        context: Option<&ResolvedContextPackReceipt>,
5406        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5407    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5408        let Some((profile, has_environment_overlay, bundle_layout)) =
5409            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5410        else {
5411            return Ok(None);
5412        };
5413        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5414            failure(
5415                NodeFailureCode::BackendOperationFailed,
5416                "session environment materializer is unavailable",
5417            )
5418        })?;
5419        let materialization_failure_code = if context.is_some() {
5420            NodeFailureCode::ContextPackMaterializationFailed
5421        } else if bundle.is_some() {
5422            NodeFailureCode::BundleMaterializationFailed
5423        } else {
5424            NodeFailureCode::BackendOperationFailed
5425        };
5426        let id = MaterializationId::new(format!(
5427            "mat-{}-{}-{}",
5428            self.incarnation_id,
5429            address.session.instance_id.0,
5430            address.session.generation.0,
5431        ))
5432        .map_err(|_| {
5433            failure(
5434                NodeFailureCode::BackendOperationFailed,
5435                "session environment materialization identity failed",
5436            )
5437        })?;
5438        let owner = MaterializationOwner::Session {
5439            incarnation_id: self.incarnation_id,
5440            instance_id: address.session.instance_id,
5441            generation: address.session.generation,
5442        };
5443        let mut ownership = materializer
5444            .begin(
5445                id.clone(),
5446                environment_profile.cloned(),
5447                bundle.cloned(),
5448                context.cloned(),
5449                owner,
5450                managed_lease_id,
5451                &profile,
5452                unix_time_ms(),
5453            )
5454            .map_err(|_| {
5455                failure(
5456                    materialization_failure_code,
5457                    "session materialization preparation failed",
5458                )
5459            })?;
5460        {
5461            let _transaction = self
5462                .state_transaction
5463                .lock()
5464                .unwrap_or_else(|poisoned| poisoned.into_inner());
5465            let duplicate = self
5466                .materializations
5467                .lock()
5468                .unwrap_or_else(|poisoned| poisoned.into_inner())
5469                .insert(id.clone(), ownership.clone());
5470            if duplicate.is_some() {
5471                return Err(failure(
5472                    NodeFailureCode::BackendOperationFailed,
5473                    "session environment materialization identity conflict",
5474                ));
5475            }
5476            if let Err(error) = self.persist_state_locked() {
5477                self.materializations
5478                    .lock()
5479                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5480                    .remove(&id);
5481                return Err(persistence_failure(error));
5482            }
5483        }
5484        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5485            Ok(environment) => environment,
5486            Err(_) => {
5487                let transaction = self
5488                    .state_transaction
5489                    .lock()
5490                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5491                self.materializations
5492                    .lock()
5493                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5494                    .insert(id.clone(), ownership.clone());
5495                if self.persist_state_locked().is_err() {
5496                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5497                }
5498                drop(transaction);
5499                if ownership.state() == MaterializationState::CleanupRequired {
5500                    let _ = self.cleanup_materialization(&id);
5501                }
5502                return Err(failure(
5503                    materialization_failure_code,
5504                    "session materialization failed",
5505                ));
5506            }
5507        };
5508        {
5509            let _transaction = self
5510                .state_transaction
5511                .lock()
5512                .unwrap_or_else(|poisoned| poisoned.into_inner());
5513            self.materializations
5514                .lock()
5515                .unwrap_or_else(|poisoned| poisoned.into_inner())
5516                .insert(id.clone(), ownership.clone());
5517            if let Err(error) = self.persist_state_locked() {
5518                drop(_transaction);
5519                let _ = self.cleanup_materialization(&id);
5520                return Err(persistence_failure(error));
5521            }
5522        }
5523        let guard = SessionMaterializationGuard {
5524            shared: self,
5525            id: Some(id),
5526            provider_home: Some(ownership.provider_home().to_path_buf()),
5527        };
5528        let bundle_arguments = match bundle {
5529            Some(receipt) => {
5530                let catalog = self.bundle_catalog.read()
5531                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5532                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5533                    NodeFailureCode::UnknownBundle,
5534                    "session bundle is unavailable on this node",
5535                ))?;
5536                if installed.receipt() != *receipt {
5537                    return Err(failure(
5538                        NodeFailureCode::BundleBindingMismatch,
5539                        "session bundle revision or digest changed",
5540                    ));
5541                }
5542                let layout = bundle_layout.ok_or_else(|| failure(
5543                    NodeFailureCode::BundleBindingMismatch,
5544                    "materialized bundle provider layout is unavailable",
5545                ))?;
5546                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5547                    failure(
5548                        NodeFailureCode::BundleMaterializationFailed,
5549                        "session bundle byte revalidation failed",
5550                    )
5551                })?;
5552                Some(bundle_launch_arguments(
5553                    layout,
5554                    ownership.bundle_root(),
5555                    ownership.provider_home(),
5556                ).map_err(|_| failure(
5557                    NodeFailureCode::BundleBindingMismatch,
5558                    "session bundle launch binding is invalid",
5559                ))?)
5560            }
5561            None => None,
5562        };
5563        if let Some(receipt) = context {
5564            materializer
5565                .revalidate_context(&ownership, receipt)
5566                .map_err(|_| failure(
5567                    NodeFailureCode::ContextPackMaterializationFailed,
5568                    "session context pack byte revalidation failed",
5569                ))?;
5570        }
5571        let transport = match mode {
5572            SessionMode::Pty => TransportKind::Pty,
5573            SessionMode::Inline => TransportKind::Pipe,
5574            SessionMode::Acp => TransportKind::Acp,
5575        };
5576        let overlay = if let Some(extra_args) = bundle_arguments {
5577            Some(PreparedNativeLaunchOverlay::Instance(
5578                NativeInstanceLaunchOverlay::new(
5579                    provider.clone(),
5580                    transport,
5581                    if has_environment_overlay {
5582                        environment
5583                    } else {
5584                        Vec::new()
5585                    },
5586                    extra_args,
5587                )
5588                .map_err(|_| {
5589                    failure(
5590                        NodeFailureCode::BundleBindingMismatch,
5591                        "session bundle launch overlay is invalid",
5592                    )
5593                })?,
5594            ))
5595        } else if has_environment_overlay {
5596            let context_only = environment_profile.is_none()
5597                && bundle.is_none()
5598                && context.is_some();
5599            let overlay = if context_only {
5600                NativeLaunchEnvironmentOverlay::new_context_root(
5601                    provider.clone(),
5602                    transport,
5603                    environment,
5604                )
5605                .map_err(|_| {
5606                    failure(
5607                        NodeFailureCode::ContextPackMaterializationFailed,
5608                        "context-only launch overlay is invalid",
5609                    )
5610                })?
5611            } else {
5612                NativeLaunchEnvironmentOverlay::new(
5613                    provider.clone(),
5614                    transport,
5615                    environment,
5616                )
5617                .map_err(|_| {
5618                    failure(
5619                        NodeFailureCode::BackendOperationFailed,
5620                        "session environment overlay is invalid",
5621                    )
5622                })?
5623            };
5624            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5625        } else {
5626            None
5627        };
5628        Ok(Some((overlay, guard)))
5629    }
5630
5631    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5632        let Some(mut ownership) = self
5633            .materializations
5634            .lock()
5635            .unwrap_or_else(|poisoned| poisoned.into_inner())
5636            .get(id)
5637            .cloned()
5638        else {
5639            return Ok(());
5640        };
5641        if ownership.state() != MaterializationState::CleanupRequired {
5642            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5643                failure(
5644                    NodeFailureCode::BackendOperationFailed,
5645                    "session environment cleanup state is invalid",
5646                )
5647            })?;
5648        }
5649        {
5650            let _transaction = self
5651                .state_transaction
5652                .lock()
5653                .unwrap_or_else(|poisoned| poisoned.into_inner());
5654            let previous = self
5655                .materializations
5656                .lock()
5657                .unwrap_or_else(|poisoned| poisoned.into_inner())
5658                .insert(id.clone(), ownership.clone());
5659            if let Err(error) = self.persist_state_locked() {
5660                let mut materializations = self
5661                    .materializations
5662                    .lock()
5663                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5664                if let Some(previous) = previous {
5665                    materializations.insert(id.clone(), previous);
5666                }
5667                return Err(persistence_failure(error));
5668            }
5669        }
5670        let cleanup = self
5671            .session_environment_materializer
5672            .as_ref()
5673            .ok_or(())
5674            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5675        if cleanup.is_err() {
5676            let _ = ownership.mark_recovery_required(unix_time_ms());
5677            self.materializations
5678                .lock()
5679                .unwrap_or_else(|poisoned| poisoned.into_inner())
5680                .insert(id.clone(), ownership);
5681            if self.persist_state().is_err() {
5682                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5683            }
5684            return Err(failure(
5685                NodeFailureCode::BackendOperationFailed,
5686                "session environment cleanup requires recovery",
5687            ));
5688        }
5689        let _transaction = self
5690            .state_transaction
5691            .lock()
5692            .unwrap_or_else(|poisoned| poisoned.into_inner());
5693        self.materializations
5694            .lock()
5695            .unwrap_or_else(|poisoned| poisoned.into_inner())
5696            .remove(id);
5697        if let Err(error) = self.persist_state_locked() {
5698            self.materializations
5699                .lock()
5700                .unwrap_or_else(|poisoned| poisoned.into_inner())
5701                .insert(id.clone(), ownership);
5702            return Err(persistence_failure(error));
5703        }
5704        Ok(())
5705    }
5706
5707    fn resolve_record_materialization(
5708        &self,
5709        record_id: &SessionRecordId,
5710        provider: &AgentId,
5711        mode: SessionMode,
5712        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5713        bundle: Option<&ResolvedBundleReceipt>,
5714        context: Option<&ResolvedContextPackReceipt>,
5715    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5716        let ownership = {
5717            let materializations = self
5718                .materializations
5719                .lock()
5720                .unwrap_or_else(|poisoned| poisoned.into_inner());
5721            let mut matches = materializations.values().filter(|record| {
5722                record.owner()
5723                    == &MaterializationOwner::Record {
5724                        record_id: record_id.clone(),
5725                    }
5726            });
5727            let ownership = matches.next().cloned();
5728            if matches.next().is_some() {
5729                return Err(failure(
5730                    NodeFailureCode::BackendOperationFailed,
5731                    "managed session has conflicting environment ownership",
5732                ));
5733            }
5734            ownership
5735        };
5736        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5737            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5738                failure(
5739                    NodeFailureCode::ContextPackMaterializationFailed,
5740                    "session context materializer is unavailable",
5741                )
5742            })?;
5743            let pack = materializer
5744                .revalidate_context(ownership, receipt)
5745                .map_err(|_| {
5746                    self.mark_materialization_recovery_required(ownership.id());
5747                    failure(
5748                        NodeFailureCode::ContextPackMaterializationFailed,
5749                        "managed session context pack revalidation failed",
5750                    )
5751                })?;
5752            self.context_catalog
5753                .write()
5754                .unwrap_or_else(|poisoned| poisoned.into_inner())
5755                .insert(pack)
5756                .map_err(|_| failure(
5757                    NodeFailureCode::ContextPackMaterializationFailed,
5758                    "managed session context pack catalog restore failed",
5759                ))?;
5760        }
5761        let materialization = self.materialization_profile(
5762            provider,
5763            mode,
5764            environment_profile,
5765            bundle,
5766            context,
5767        )?;
5768        match (ownership, materialization) {
5769            (None, None) => Ok(None),
5770            (None, Some(_)) | (Some(_), None) => Err(failure(
5771                NodeFailureCode::BackendOperationFailed,
5772                "managed session materialization is unavailable",
5773            )),
5774            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5775                if ownership.environment_profile() != environment_profile
5776                    || ownership.bundle() != bundle
5777                    || ownership.context() != context
5778                    || ownership.state() != MaterializationState::Ready
5779                {
5780                    self.mark_materialization_recovery_required(ownership.id());
5781                    return Err(failure(
5782                        NodeFailureCode::BackendOperationFailed,
5783                        "managed session environment ownership requires recovery",
5784                    ));
5785                }
5786                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5787                    failure(
5788                        NodeFailureCode::BackendOperationFailed,
5789                        "session environment materializer is unavailable",
5790                    )
5791                })?;
5792                if materializer.revalidate(&ownership).is_err() {
5793                    self.mark_materialization_recovery_required(ownership.id());
5794                    return Err(failure(
5795                        NodeFailureCode::BackendOperationFailed,
5796                        "managed session environment revalidation failed",
5797                    ));
5798                }
5799                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5800                    let catalog = self.bundle_catalog.read()
5801                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5802                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5803                        return Err(failure(
5804                            NodeFailureCode::UnknownBundle,
5805                            "managed session bundle is unavailable",
5806                        ));
5807                    };
5808                    let layout = bundle_layout.ok_or_else(|| failure(
5809                        NodeFailureCode::BundleBindingMismatch,
5810                        "record bundle provider layout is unavailable",
5811                    ))?;
5812                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5813                        drop(catalog);
5814                        self.mark_materialization_recovery_required(ownership.id());
5815                        return Err(failure(
5816                            NodeFailureCode::BundleMaterializationFailed,
5817                            "managed session bundle revalidation failed",
5818                        ));
5819                    }
5820                    Some(bundle_launch_arguments(
5821                        layout,
5822                        ownership.bundle_root(),
5823                        ownership.provider_home(),
5824                    ).map_err(|_| failure(
5825                        NodeFailureCode::BundleBindingMismatch,
5826                        "managed session bundle launch binding is invalid",
5827                    ))?)
5828                } else {
5829                    None
5830                };
5831                if !has_environment_materialization {
5832                    let overlay = bundle_arguments.map(|extra_args| {
5833                        NativeInstanceLaunchOverlay::new(
5834                            provider.clone(),
5835                            TransportKind::Pty,
5836                            Vec::new(),
5837                            extra_args,
5838                        )
5839                        .map(PreparedNativeLaunchOverlay::Instance)
5840                        .map_err(|_| failure(
5841                            NodeFailureCode::BundleBindingMismatch,
5842                            "managed session bundle launch overlay is invalid",
5843                        ))
5844                    }).transpose()?;
5845                    return Ok(Some((ownership.id().clone(), overlay)));
5846                }
5847                let environment = match materializer.resolve_environment(&ownership, &profile) {
5848                    Ok(environment) => environment,
5849                    Err(
5850                        SessionEnvironmentMaterializeError::SecretUnavailable
5851                        | SessionEnvironmentMaterializeError::SecretDenied
5852                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5853                    ) => {
5854                        return Err(failure(
5855                            NodeFailureCode::BackendOperationFailed,
5856                            "managed session environment resolution failed",
5857                        ));
5858                    }
5859                    Err(
5860                        SessionEnvironmentMaterializeError::InvalidRoot
5861                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5862                        | SessionEnvironmentMaterializeError::Filesystem(_)
5863                        | SessionEnvironmentMaterializeError::Record(_),
5864                    ) => {
5865                        self.mark_materialization_recovery_required(ownership.id());
5866                        return Err(failure(
5867                            NodeFailureCode::BackendOperationFailed,
5868                            "managed session environment resolution failed",
5869                        ));
5870                    }
5871                };
5872                let transport = match mode {
5873                    SessionMode::Pty => TransportKind::Pty,
5874                    SessionMode::Inline => TransportKind::Pipe,
5875                    SessionMode::Acp => TransportKind::Acp,
5876                };
5877                let overlay = match bundle_arguments {
5878                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5879                        NativeInstanceLaunchOverlay::new(
5880                            provider.clone(),
5881                            transport,
5882                            environment,
5883                            extra_args,
5884                        )
5885                        .map_err(|_| failure(
5886                            NodeFailureCode::BundleBindingMismatch,
5887                            "managed session bundle launch overlay is invalid",
5888                        ))?,
5889                    ),
5890                    None => PreparedNativeLaunchOverlay::Environment(
5891                        NativeLaunchEnvironmentOverlay::new(
5892                            provider.clone(),
5893                            transport,
5894                            environment,
5895                        )
5896                        .map_err(|_| {
5897                            failure(
5898                                NodeFailureCode::BackendOperationFailed,
5899                                "managed session environment overlay is invalid",
5900                            )
5901                        })?,
5902                    ),
5903                };
5904                Ok(Some((ownership.id().clone(), Some(overlay))))
5905            }
5906        }
5907    }
5908
5909    fn destroy_record_materialization(
5910        &self,
5911        record_id: &SessionRecordId,
5912    ) -> Result<bool, NodeFailure> {
5913        let ids = self
5914            .materializations
5915            .lock()
5916            .unwrap_or_else(|poisoned| poisoned.into_inner())
5917            .values()
5918            .filter_map(|record| {
5919                (record.owner()
5920                    == &MaterializationOwner::Record {
5921                        record_id: record_id.clone(),
5922                    })
5923                .then(|| record.id().clone())
5924            })
5925            .collect::<Vec<_>>();
5926        if ids.len() > 1 {
5927            return Err(failure(
5928                NodeFailureCode::BackendOperationFailed,
5929                "managed session has conflicting environment ownership",
5930            ));
5931        }
5932        let Some(id) = ids.first().cloned() else {
5933            return Ok(false);
5934        };
5935        let (terminal_record, mut cleanup_ownership) = {
5936            let _transaction = self
5937                .state_transaction
5938                .lock()
5939                .unwrap_or_else(|poisoned| poisoned.into_inner());
5940            let original_record = self.record(record_id)?;
5941            let mut terminal_record = original_record.clone();
5942            terminal_record.provider_session = None;
5943            terminal_record.active_session = None;
5944            terminal_record.state = ManagedSessionState::Unavailable;
5945            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5946            terminal_record.updated_at_unix_ms = unix_time_ms();
5947            let original_ownership = self
5948                .materializations
5949                .lock()
5950                .unwrap_or_else(|poisoned| poisoned.into_inner())
5951                .get(&id)
5952                .cloned()
5953                .ok_or_else(|| {
5954                    failure(
5955                        NodeFailureCode::BackendOperationFailed,
5956                        "managed session environment ownership disappeared during cleanup",
5957                    )
5958                })?;
5959            let mut cleanup_ownership = original_ownership.clone();
5960            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5961                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5962                    failure(
5963                        NodeFailureCode::BackendOperationFailed,
5964                        "managed session environment cleanup state is invalid",
5965                    )
5966                })?;
5967            }
5968            self.session_records
5969                .lock()
5970                .unwrap_or_else(|poisoned| poisoned.into_inner())
5971                .records
5972                .insert(record_id.clone(), terminal_record.clone());
5973            self.materializations
5974                .lock()
5975                .unwrap_or_else(|poisoned| poisoned.into_inner())
5976                .insert(id.clone(), cleanup_ownership.clone());
5977            if let Err(error) = self.persist_state_locked() {
5978                self.session_records
5979                    .lock()
5980                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5981                    .records
5982                    .insert(record_id.clone(), original_record);
5983                self.materializations
5984                    .lock()
5985                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5986                    .insert(id.clone(), original_ownership);
5987                return Err(persistence_failure(error));
5988            }
5989            (terminal_record, cleanup_ownership)
5990        };
5991        let cleanup = self
5992            .session_environment_materializer
5993            .as_ref()
5994            .ok_or(())
5995            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5996        if cleanup.is_err() {
5997            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5998            self.materializations
5999                .lock()
6000                .unwrap_or_else(|poisoned| poisoned.into_inner())
6001                .insert(id.clone(), cleanup_ownership);
6002            if self.persist_state().is_err() {
6003                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6004            }
6005            return Err(failure(
6006                NodeFailureCode::BackendOperationFailed,
6007                "managed session environment cleanup requires recovery",
6008            ));
6009        }
6010        let _transaction = self
6011            .state_transaction
6012            .lock()
6013            .unwrap_or_else(|poisoned| poisoned.into_inner());
6014        self.remove_record_memory(record_id);
6015        self.materializations
6016            .lock()
6017            .unwrap_or_else(|poisoned| poisoned.into_inner())
6018            .remove(&id);
6019        if let Err(error) = self.persist_state_locked() {
6020            self.session_records
6021                .lock()
6022                .unwrap_or_else(|poisoned| poisoned.into_inner())
6023                .records
6024                .insert(record_id.clone(), terminal_record);
6025            self.materializations
6026                .lock()
6027                .unwrap_or_else(|poisoned| poisoned.into_inner())
6028                .insert(id, cleanup_ownership);
6029            return Err(persistence_failure(error));
6030        }
6031        Ok(true)
6032    }
6033
6034    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6035        let changed = {
6036            let mut materializations = self
6037                .materializations
6038                .lock()
6039                .unwrap_or_else(|poisoned| poisoned.into_inner());
6040            let Some(ownership) = materializations.get_mut(id) else {
6041                return;
6042            };
6043            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6044        };
6045        if changed && self.persist_state().is_err() {
6046            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6047        }
6048    }
6049
6050    fn reconcile_materializations(&self) {
6051        let records = self
6052            .materializations
6053            .lock()
6054            .unwrap_or_else(|poisoned| poisoned.into_inner())
6055            .values()
6056            .cloned()
6057            .collect::<Vec<_>>();
6058        for mut ownership in records {
6059            let result = match (ownership.state(), ownership.owner()) {
6060                (MaterializationState::Preparing, _)
6061                | (MaterializationState::CleanupRequired, _) => {
6062                    self.cleanup_materialization(ownership.id())
6063                }
6064                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6065                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6066                        self.materializations
6067                            .lock()
6068                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6069                            .insert(ownership.id().clone(), ownership.clone());
6070                        self.persist_state().map_err(persistence_failure)
6071                    } else {
6072                        Err(failure(
6073                            NodeFailureCode::BackendOperationFailed,
6074                            "session environment reconciliation failed",
6075                        ))
6076                    }
6077                }
6078                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6079                    .session_environment_materializer
6080                    .as_ref()
6081                    .ok_or_else(|| {
6082                        failure(
6083                            NodeFailureCode::BackendOperationFailed,
6084                            "session environment materializer is unavailable",
6085                        )
6086                    })
6087                    .and_then(|materializer| {
6088                        materializer.revalidate(&ownership).map_err(|_| failure(
6089                            NodeFailureCode::BackendOperationFailed,
6090                            "session materialization revalidation failed",
6091                        ))?;
6092                        let record = self.record(record_id)?;
6093                        if record.environment_profile.as_ref() != ownership.environment_profile()
6094                            || record.bundle.as_ref() != ownership.bundle()
6095                            || record.context.as_ref() != ownership.context()
6096                        {
6097                            return Err(failure(
6098                                NodeFailureCode::BackendOperationFailed,
6099                                "session materialization ownership changed after restart",
6100                            ));
6101                        }
6102                        if let Some(receipt) = ownership.bundle() {
6103                            let catalog = self.bundle_catalog.read()
6104                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6105                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6106                                NodeFailureCode::UnknownBundle,
6107                                "session bundle is unavailable after restart",
6108                            ))?;
6109                            if bundle.receipt() != *receipt {
6110                                return Err(failure(
6111                                    NodeFailureCode::BundleBindingMismatch,
6112                                    "session bundle changed after restart",
6113                                ));
6114                            }
6115                            let layout = self.bundle_layout(
6116                                &record.provider,
6117                                record.mode,
6118                                record.environment_profile.as_ref(),
6119                                bundle,
6120                            )?;
6121                            materializer
6122                                .revalidate_bundle(&ownership, bundle, layout)
6123                                .map_err(|_| failure(
6124                                    NodeFailureCode::BundleMaterializationFailed,
6125                                    "session bundle revalidation failed",
6126                                ))?;
6127                        }
6128                        if let Some(receipt) = ownership.context() {
6129                            let pack = materializer
6130                                .revalidate_context(&ownership, receipt)
6131                                .map_err(|_| {
6132                                    failure(
6133                                        NodeFailureCode::ContextPackMaterializationFailed,
6134                                        "session context revalidation failed",
6135                                    )
6136                                })?;
6137                            self.context_catalog
6138                                .write()
6139                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6140                                .insert(pack)
6141                                .map_err(|_| {
6142                                    failure(
6143                                        NodeFailureCode::ContextPackMaterializationFailed,
6144                                        "session context catalog restore failed",
6145                                    )
6146                                })?;
6147                        }
6148                        Ok(())
6149                    }),
6150                (MaterializationState::RecoveryRequired, _) => Ok(()),
6151            };
6152            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6153                let _ = ownership.mark_recovery_required(unix_time_ms());
6154                self.materializations
6155                    .lock()
6156                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6157                    .insert(ownership.id().clone(), ownership);
6158                if self.persist_state().is_err() {
6159                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6160                }
6161            }
6162        }
6163        let materialized_profiles = self
6164            .environment_materialization_profiles
6165            .read()
6166            .unwrap_or_else(|poisoned| poisoned.into_inner())
6167            .keys()
6168            .cloned()
6169            .collect::<Vec<_>>();
6170        let ownership = self
6171            .materializations
6172            .lock()
6173            .unwrap_or_else(|poisoned| poisoned.into_inner())
6174            .values()
6175            .cloned()
6176            .collect::<Vec<_>>();
6177        let changed = {
6178            let mut session_records = self
6179                .session_records
6180                .lock()
6181                .unwrap_or_else(|poisoned| poisoned.into_inner());
6182            let mut changed = false;
6183            for record in session_records.records.values_mut() {
6184                let environment_profile = record.environment_profile.as_ref();
6185                if environment_profile.is_none()
6186                    && record.bundle.is_none()
6187                    && record.context.is_none()
6188                {
6189                    continue;
6190                }
6191                if record.bundle.is_none()
6192                    && environment_profile.is_some_and(|profile| {
6193                        !materialized_profiles.contains(&profile.profile_id)
6194                    })
6195                {
6196                    continue;
6197                }
6198                let has_exact_ownership = ownership.iter().any(|candidate| {
6199                    candidate.state() == MaterializationState::Ready
6200                        && candidate.environment_profile() == environment_profile
6201                        && candidate.bundle() == record.bundle.as_ref()
6202                        && candidate.context() == record.context.as_ref()
6203                        && candidate.owner()
6204                            == &MaterializationOwner::Record {
6205                                record_id: record.record_id.clone(),
6206                            }
6207                });
6208                if !has_exact_ownership {
6209                    record.provider_session = None;
6210                    record.active_session = None;
6211                    record.state = ManagedSessionState::Unavailable;
6212                    record.last_error = Some(if record.bundle.is_some() {
6213                        "bundle-unavailable".to_owned()
6214                    } else if record.context.is_some() {
6215                        "context-unavailable".to_owned()
6216                    } else {
6217                        "environment-profile-unavailable".to_owned()
6218                    });
6219                    record.updated_at_unix_ms = unix_time_ms();
6220                    changed = true;
6221                }
6222            }
6223            changed
6224        };
6225        if changed && self.persist_state().is_err() {
6226            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6227        }
6228    }
6229
6230    fn replay_spawn_spec(
6231        &self,
6232        spec: &SpawnSpec,
6233        now: Instant,
6234    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6235        let mut cache = self
6236            .spawn_idempotency
6237            .lock()
6238            .unwrap_or_else(|poisoned| poisoned.into_inner());
6239        cache.entries.retain(|_, entry| entry.expires_at > now);
6240        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6241            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6242                return Err(failure(
6243                    NodeFailureCode::SpawnIdempotencyConflict,
6244                    "spawn idempotency key was reused across spawn kinds",
6245                ));
6246            };
6247            if existing != spec {
6248                return Err(failure(
6249                    NodeFailureCode::SpawnIdempotencyConflict,
6250                    "spawn idempotency key was reused with a different specification",
6251                ));
6252            }
6253            return Ok(Some(result.clone()));
6254        }
6255        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6256            return Err(failure(
6257                NodeFailureCode::SpawnIdempotencyCapacity,
6258                "spawn idempotency cache reached its bounded capacity",
6259            ));
6260        }
6261        Ok(None)
6262    }
6263
6264    fn remember_spawn_spec(
6265        &self,
6266        spec: SpawnSpec,
6267        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6268        accepted_at: Instant,
6269    ) {
6270        self
6271            .spawn_idempotency
6272            .lock()
6273            .unwrap_or_else(|poisoned| poisoned.into_inner())
6274            .entries
6275            .insert(
6276                spec.idempotency_key.clone(),
6277                SpawnIdempotencyEntry {
6278                    value: SpawnIdempotencyValue::Standard { spec, result },
6279                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280                },
6281            );
6282    }
6283
6284    fn claim_harness_mcp_spawn(
6285        &self,
6286        _reservation_id: &HarnessMcpReservationId,
6287        _activation_digest: &HarnessMcpActivationDigest,
6288        spec: &SpawnSpec,
6289        now: Instant,
6290    ) -> Result<(), NodeFailure> {
6291        let mut cache = self.spawn_idempotency.lock()
6292            .unwrap_or_else(|poisoned| poisoned.into_inner());
6293        cache.entries.retain(|_, entry| entry.expires_at > now);
6294        if cache.entries.contains_key(&spec.idempotency_key) {
6295            return Err(failure(
6296                NodeFailureCode::SpawnIdempotencyConflict,
6297                "spawn idempotency key was reused across spawn kinds",
6298            ));
6299        }
6300        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6301            return Err(failure(
6302                NodeFailureCode::SpawnIdempotencyCapacity,
6303                "spawn idempotency cache reached its bounded capacity",
6304            ));
6305        }
6306        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6307            value: SpawnIdempotencyValue::HarnessMcp,
6308            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6309        });
6310        Ok(())
6311    }
6312
6313    fn replay_managed_spawn(
6314        &self,
6315        request: &ManagedWorktreeSpawnRequest,
6316        now: Instant,
6317    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6318        let mut cache = self.spawn_idempotency.lock()
6319            .unwrap_or_else(|poisoned| poisoned.into_inner());
6320        cache.entries.retain(|_, entry| entry.expires_at > now);
6321        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6322            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6323                return Err(failure(
6324                    NodeFailureCode::SpawnIdempotencyConflict,
6325                    "spawn idempotency key was reused across spawn kinds",
6326                ));
6327            };
6328            if existing != request {
6329                return Err(failure(
6330                    NodeFailureCode::SpawnIdempotencyConflict,
6331                    "spawn idempotency key was reused with a different managed specification",
6332                ));
6333            }
6334            return Ok(Some(result.clone()));
6335        }
6336        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6337            return Err(failure(
6338                NodeFailureCode::SpawnIdempotencyCapacity,
6339                "spawn idempotency cache reached its bounded capacity",
6340            ));
6341        }
6342        Ok(None)
6343    }
6344
6345    fn remember_managed_spawn(
6346        &self,
6347        request: ManagedWorktreeSpawnRequest,
6348        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6349        accepted_at: Instant,
6350    ) {
6351        self.spawn_idempotency.lock()
6352            .unwrap_or_else(|poisoned| poisoned.into_inner())
6353            .entries
6354            .insert(
6355                request.spawn_spec.idempotency_key.clone(),
6356                SpawnIdempotencyEntry {
6357                    value: SpawnIdempotencyValue::Managed { request, result },
6358                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6359                },
6360            );
6361    }
6362
6363    fn remember_managed_spawn_attempt(
6364        &self,
6365        durable_v2_key: Option<&SpawnIdempotencyKey>,
6366        request: ManagedWorktreeSpawnRequest,
6367        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6368        accepted_at: Instant,
6369    ) {
6370        if durable_v2_key.is_none() {
6371            self.remember_managed_spawn(request, result, accepted_at);
6372        }
6373    }
6374
6375    fn acquire_durable_managed_spawn_v2(
6376        &self,
6377        request: &ManagedWorktreeSpawnRequestV2,
6378        request_digest: String,
6379    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6380        if self.state_path.is_none() {
6381            return Err(failure(
6382                NodeFailureCode::BackendOperationFailed,
6383                "managed worktree V2 requires durable node state",
6384            ));
6385        }
6386        let _transaction = self.state_transaction
6387            .lock()
6388            .unwrap_or_else(|poisoned| poisoned.into_inner());
6389        let key = request.spawn_spec.idempotency_key.clone();
6390        let mut previous = None;
6391        {
6392            let mut replays = self.managed_spawn_replays
6393                .lock()
6394                .unwrap_or_else(|poisoned| poisoned.into_inner());
6395            if let Some(record) = replays.get_mut(&key) {
6396                if record.request_digest != request_digest {
6397                    return Err(failure(
6398                        NodeFailureCode::SpawnIdempotencyConflict,
6399                        "managed worktree V2 idempotency key was reused with a different request",
6400                    ));
6401                }
6402                match &record.state {
6403                    ManagedWorktreeSpawnReplayStateV10::Pending {
6404                        owner_incarnation_id,
6405                        lease_id: None,
6406                    } if owner_incarnation_id != &self.incarnation_id => {
6407                        previous = Some(record.clone());
6408                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6409                            owner_incarnation_id: self.incarnation_id,
6410                            lease_id: None,
6411                        };
6412                        record.updated_at_unix_ms = unix_time_ms();
6413                    }
6414                    ManagedWorktreeSpawnReplayStateV10::Pending {
6415                        lease_id: None,
6416                        ..
6417                    } => {
6418                        return Err(failure(
6419                            NodeFailureCode::BackendBusy,
6420                            "managed worktree V2 reservation is already active",
6421                        ));
6422                    }
6423                    ManagedWorktreeSpawnReplayStateV10::Pending {
6424                        lease_id: Some(_),
6425                        ..
6426                    } => {
6427                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6428                    }
6429                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6430                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6431                            receipt.clone(),
6432                        ));
6433                    }
6434                }
6435            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6436                return Err(failure(
6437                    NodeFailureCode::SpawnIdempotencyCapacity,
6438                    "durable managed worktree replay ledger reached its bounded capacity",
6439                ));
6440            } else {
6441                let now = unix_time_ms();
6442                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6443                    idempotency_key: key.clone(),
6444                    request_digest,
6445                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6446                    profile_id: request.worktree_profile_id.clone(),
6447                    expected_profile_revision: request.expected_profile_revision.clone(),
6448                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6449                        owner_incarnation_id: self.incarnation_id,
6450                        lease_id: None,
6451                    },
6452                    created_at_unix_ms: now,
6453                    updated_at_unix_ms: now,
6454                });
6455            }
6456        }
6457        if let Err(error) = self.persist_state_locked() {
6458            let mut replays = self.managed_spawn_replays
6459                .lock()
6460                .unwrap_or_else(|poisoned| poisoned.into_inner());
6461            match previous {
6462                Some(previous) => {
6463                    replays.insert(key, previous);
6464                }
6465                None => {
6466                    replays.remove(&key);
6467                }
6468            }
6469            return Err(persistence_failure(error));
6470        }
6471        Ok(DurableManagedSpawnReplayDecision::Reserved)
6472    }
6473
6474    fn link_durable_managed_spawn_v2_lease_locked(
6475        &self,
6476        key: &SpawnIdempotencyKey,
6477        lease_id: &ManagedWorktreeLeaseId,
6478    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6479        let mut replays = self.managed_spawn_replays
6480            .lock()
6481            .unwrap_or_else(|poisoned| poisoned.into_inner());
6482        let record = replays.get_mut(key).ok_or_else(|| {
6483            failure(
6484                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6485                "managed worktree V2 reservation disappeared before allocation",
6486            )
6487        })?;
6488        let previous = record.clone();
6489        match &mut record.state {
6490            ManagedWorktreeSpawnReplayStateV10::Pending {
6491                lease_id: current,
6492                ..
6493            }
6494                if current.is_none() =>
6495            {
6496                *current = Some(lease_id.clone());
6497                record.updated_at_unix_ms = unix_time_ms();
6498                Ok(previous)
6499            }
6500            _ => Err(failure(
6501                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6502                "managed worktree V2 reservation is not allocation-ready",
6503            )),
6504        }
6505    }
6506
6507    fn restore_durable_managed_spawn_v2_locked(
6508        &self,
6509        key: &SpawnIdempotencyKey,
6510        previous: ManagedWorktreeSpawnReplayRecordV10,
6511    ) {
6512        self.managed_spawn_replays
6513            .lock()
6514            .unwrap_or_else(|poisoned| poisoned.into_inner())
6515            .insert(key.clone(), previous);
6516    }
6517
6518    fn commit_durable_managed_spawn_v2(
6519        &self,
6520        request: &ManagedWorktreeSpawnRequestV2,
6521        receipt: ManagedWorktreeSpawnReceipt,
6522    ) -> Result<(), NodeFailure> {
6523        let _transaction = self.state_transaction
6524            .lock()
6525            .unwrap_or_else(|poisoned| poisoned.into_inner());
6526        let key = &request.spawn_spec.idempotency_key;
6527        let previous = {
6528            let mut replays = self.managed_spawn_replays
6529                .lock()
6530                .unwrap_or_else(|poisoned| poisoned.into_inner());
6531            let record = replays.get_mut(key).ok_or_else(|| {
6532                failure(
6533                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6534                    "managed worktree V2 reservation disappeared before commit",
6535                )
6536            })?;
6537            let previous = record.clone();
6538            match &record.state {
6539                ManagedWorktreeSpawnReplayStateV10::Pending {
6540                    lease_id: Some(lease_id),
6541                    ..
6542                } if lease_id == &receipt.lease.lease_id => {}
6543                _ => {
6544                    return Err(failure(
6545                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6546                        "managed worktree V2 reservation is not linked to the spawned lease",
6547                    ));
6548                }
6549            }
6550            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6551            record.updated_at_unix_ms = unix_time_ms();
6552            previous
6553        };
6554        if let Err(error) = self.persist_state_locked() {
6555            self.restore_durable_managed_spawn_v2_locked(key, previous);
6556            return Err(persistence_failure(error));
6557        }
6558        Ok(())
6559    }
6560
6561    fn remove_unallocated_durable_managed_spawn_v2(
6562        &self,
6563        request: &ManagedWorktreeSpawnRequestV2,
6564    ) -> Result<(), NodeFailure> {
6565        let _transaction = self.state_transaction
6566            .lock()
6567            .unwrap_or_else(|poisoned| poisoned.into_inner());
6568        let key = &request.spawn_spec.idempotency_key;
6569        let removed = {
6570            let mut replays = self.managed_spawn_replays
6571                .lock()
6572                .unwrap_or_else(|poisoned| poisoned.into_inner());
6573            match replays.get(key).map(|record| &record.state) {
6574                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6575                    lease_id: None,
6576                    ..
6577                }) => {
6578                    replays.remove(key)
6579                }
6580                _ => None,
6581            }
6582        };
6583        let Some(removed) = removed else {
6584            return Ok(());
6585        };
6586        if let Err(error) = self.persist_state_locked() {
6587            self.managed_spawn_replays
6588                .lock()
6589                .unwrap_or_else(|poisoned| poisoned.into_inner())
6590                .insert(key.clone(), removed);
6591            return Err(persistence_failure(error));
6592        }
6593        Ok(())
6594    }
6595
6596    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6597        if spec.target.node_id != self.node_id {
6598            return Err(failure(
6599                NodeFailureCode::SpawnTargetMismatch,
6600                "spawn target does not match this node",
6601            ));
6602        }
6603        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6604            failure(
6605                NodeFailureCode::UnknownSpawnProfile,
6606                "spawn profile is unavailable on this node",
6607            )
6608        })?;
6609        let resolved = spec.resolve(defaults).map_err(|error| match error {
6610            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6611                NodeFailureCode::SpawnProfileRevisionMismatch,
6612                "spawn profile revision does not match the loaded profile",
6613            ),
6614            _ => failure(
6615                NodeFailureCode::InvalidRequest,
6616                "spawn specification could not be resolved",
6617            ),
6618        })?;
6619        if resolved.target.worktree_id.is_some() {
6620            self.require_worktree_service(&resolved.target.workspace_id)?;
6621        }
6622        let environment_profile = self.resolve_environment_profile(&resolved)?;
6623        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6624        self.resolve_context(&resolved)?;
6625        Ok(resolved)
6626    }
6627
6628    async fn resolve_spawn_workspace(
6629        &self,
6630        resolved: &ResolvedSpawnSpec,
6631        deadline: Instant,
6632    ) -> Result<WorkspaceId, NodeFailure> {
6633        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6634            return Ok(resolved.target.workspace_id.clone());
6635        };
6636        if self.managed_worktrees.lock()
6637            .unwrap_or_else(|poisoned| poisoned.into_inner())
6638            .lease_for_workspace(worktree_id)
6639            .is_some()
6640        {
6641            return Err(failure(
6642                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6643                "managed worktree targets may only be spawned through their lease request",
6644            ));
6645        }
6646        self.require_worktree_service(&resolved.target.workspace_id)?;
6647        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6648        let selected_root = self.workspace_root(worktree_id)?;
6649        let remaining = spawn_deadline_remaining(deadline)?;
6650        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6651            .await
6652            .map_err(|_| {
6653                failure(
6654                    NodeFailureCode::SpawnDeadlineExceeded,
6655                    "Git worktree selection exceeded the spawn deadline",
6656                )
6657            })?
6658            .map_err(git_worktree_failure)?;
6659        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6660        Ok(worktree_id.clone())
6661    }
6662
6663    async fn spawn_from_spec(
6664        &self,
6665        spec: SpawnSpec,
6666    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6667        self.spawn_from_spec_with_proxy(spec, None).await
6668    }
6669
6670    async fn spawn_from_spec_with_proxy(
6671        &self,
6672        spec: SpawnSpec,
6673        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6674    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6675        let accepted_at = Instant::now();
6676        if harness_mcp.is_none() {
6677            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6678                return replayed;
6679            }
6680        }
6681        let resolved = self.resolve_spawn_spec(&spec)?;
6682        let environment_profile = self.resolve_environment_profile(&resolved)?;
6683        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6684        let context = self.resolve_context(&resolved)?;
6685        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6686        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6687        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6688        let spawn_workspace_id = match spawn_workspace_id {
6689            Ok(workspace_id) => workspace_id,
6690            Err(error) => {
6691                let result = Err(error);
6692                if harness_mcp.is_none() {
6693                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6694                }
6695                return result;
6696            }
6697        };
6698        if self.managed_worktrees.lock()
6699            .unwrap_or_else(|poisoned| poisoned.into_inner())
6700            .lease_for_workspace(&spawn_workspace_id)
6701            .is_some()
6702        {
6703            let result = Err(failure(
6704                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6705                "managed worktree targets may only be spawned through their lease request",
6706            ));
6707            if harness_mcp.is_none() {
6708                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6709            }
6710            return result;
6711        }
6712        let result = self
6713            .spawn_session_with_deadline(
6714                spawn_workspace_id,
6715                resolved.provider.clone(),
6716                resolved.mode,
6717                resolved.terminal_size,
6718                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6719                environment_profile.clone(),
6720                bundle.clone(),
6721                context.clone(),
6722                None,
6723                None,
6724                SpawnRecordPolicy::Always,
6725                Some(deadline),
6726                &required_capabilities,
6727                harness_mcp,
6728                resolved.approval_level,
6729                resolved.network_allowlist.clone(),
6730                resolved.browser_profile_id.clone(),
6731            )
6732            .await
6733            .map(|(session, _runtime_policy)| {
6734                resolved.receipt_with_materialization(
6735                    self.incarnation_id,
6736                    session,
6737                    environment_profile,
6738                    bundle,
6739                    context,
6740                )
6741            });
6742        if harness_mcp.is_none() {
6743            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6744        }
6745        result
6746    }
6747
6748    async fn spawn_from_spec_with_harness_mcp(
6749        &self,
6750        reservation_id: HarnessMcpReservationId,
6751        activation_digest: HarnessMcpActivationDigest,
6752        spec: SpawnSpec,
6753        deadline_unix_ms: u64,
6754    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6755        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6756            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6757        })?;
6758        if let Some(receipt) = registry
6759            .replay_spawn(&reservation_id, &activation_digest, &spec)
6760            .map_err(harness_mcp_failure)?
6761        {
6762            return Ok(receipt);
6763        }
6764        let resolved = self.resolve_spawn_spec(&spec)?;
6765        let prepared = registry
6766            .prepare_spawn(
6767                &reservation_id,
6768                &activation_digest,
6769                &spec,
6770                deadline_unix_ms,
6771                resolved.provider.clone(),
6772            )
6773            .map_err(harness_mcp_failure)?;
6774        self.claim_harness_mcp_spawn(
6775            &reservation_id,
6776            &activation_digest,
6777            &spec,
6778            Instant::now(),
6779        )?;
6780        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6781        let mut receipt = match spawn {
6782            Ok(receipt) => receipt,
6783            Err(error) => {
6784                let _ = registry.abort(&reservation_id, &activation_digest);
6785                return Err(error);
6786            }
6787        };
6788        let record_id = self
6789            .session_bindings
6790            .lock()
6791            .unwrap_or_else(|poisoned| poisoned.into_inner())
6792            .get(&receipt.session.session.instance_id)
6793            .and_then(|binding| binding.record_id.clone());
6794        let record_id = match record_id {
6795            Some(record_id) => record_id,
6796            None => {
6797                let rollback = self.remove_session(&receipt.session).await;
6798                let _ = registry.abort(&reservation_id, &activation_digest);
6799                if let Err(rollback_error) = rollback {
6800                    return Err(rollback_error);
6801                }
6802                return Err(failure(
6803                    NodeFailureCode::BindingMismatch,
6804                    "harness MCP spawn did not retain its exact managed record",
6805                ));
6806            }
6807        };
6808        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6809            reservation_id: reservation_id.clone(),
6810            activation_digest: activation_digest.clone(),
6811        });
6812        if let Err(error) = registry.mark_spawned(
6813            &prepared,
6814            receipt.session.clone(),
6815            record_id,
6816            receipt.clone(),
6817        ) {
6818            let rollback = self.remove_session(&receipt.session).await;
6819            let _ = registry.abort(&reservation_id, &activation_digest);
6820            if let Err(rollback_error) = rollback {
6821                return Err(rollback_error);
6822            }
6823            return Err(harness_mcp_failure(error));
6824        }
6825        Ok(receipt)
6826    }
6827
6828    async fn spawn_managed_worktree(
6829        &self,
6830        request: ManagedWorktreeSpawnRequest,
6831    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6832        self.spawn_managed_worktree_inner(request, None).await
6833    }
6834
6835    async fn spawn_managed_worktree_inner(
6836        &self,
6837        request: ManagedWorktreeSpawnRequest,
6838        durable_v2_key: Option<&SpawnIdempotencyKey>,
6839    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6840        let accepted_at = Instant::now();
6841        if durable_v2_key.is_none() {
6842            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6843                return replayed;
6844            }
6845        }
6846        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6847        if request.spawn_spec.target.worktree_id.is_some() {
6848            let result = Err(failure(
6849                NodeFailureCode::InvalidRequest,
6850                "managed worktree spawn must not select a caller-provided worktree",
6851            ));
6852            self.remember_managed_spawn_attempt(
6853                durable_v2_key,
6854                request,
6855                result.clone(),
6856                accepted_at,
6857            );
6858            return result;
6859        }
6860        let environment_profile = self.resolve_environment_profile(&resolved)?;
6861        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6862        let context = self.resolve_context(&resolved)?;
6863        let source_workspace_id = resolved.target.workspace_id.clone();
6864        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6865        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6866        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6867        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6868            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6869            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6870            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6871            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6872        };
6873        let runtime_policy = timeout(
6874            spawn_deadline_remaining(deadline)?,
6875            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6876        ).await.map_err(|_| failure(
6877            NodeFailureCode::SpawnDeadlineExceeded,
6878            "provider admission exceeded the managed spawn deadline",
6879        ))??;
6880        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6881            return Err(failure(
6882                NodeFailureCode::UnsupportedSpawnCapability,
6883                "provider runtime does not admit a required spawn capability",
6884            ));
6885        }
6886        self.ensure_binding_capacity()?;
6887        let source_root = self.workspace_root(&source_workspace_id)?;
6888        profile.validate_for_workspace(&source_root)
6889            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6890        let base_timeout_ms = u64::try_from(
6891            spawn_deadline_remaining(deadline)?.as_millis(),
6892        ).unwrap_or(u64::MAX).max(1);
6893        let base_commit = resolve_base_commit_with_timeout(
6894            &source_root,
6895            profile.base(),
6896            base_timeout_ms,
6897        ).await.map_err(|error| {
6898            if Instant::now() >= deadline || error.message.contains("timed out") {
6899                failure(
6900                    NodeFailureCode::SpawnDeadlineExceeded,
6901                    "managed worktree base resolution exceeded the spawn deadline",
6902                )
6903            } else {
6904                managed_git_worktree_failure(error)
6905            }
6906        })?;
6907
6908        let lease = {
6909            let _transaction = self.state_transaction.lock()
6910                .unwrap_or_else(|poisoned| poisoned.into_inner());
6911            let previous = self.managed_worktrees.lock()
6912                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6913            let lease = self.managed_worktrees.lock()
6914                .unwrap_or_else(|poisoned| poisoned.into_inner())
6915                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6916                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6917            let previous_replay = match durable_v2_key
6918                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6919                .transpose()
6920            {
6921                Ok(previous) => previous,
6922                Err(error) => {
6923                    *self.managed_worktrees.lock()
6924                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6925                    return Err(error);
6926                }
6927            };
6928            if let Err(error) = self.persist_state_locked() {
6929                *self.managed_worktrees.lock()
6930                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6931                if let (Some(key), Some(previous_replay)) =
6932                    (durable_v2_key, previous_replay)
6933                {
6934                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6935                }
6936                return Err(persistence_failure(error));
6937            }
6938            lease
6939        };
6940        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6941        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6942            self.managed_worktrees.lock()
6943                .unwrap_or_else(|poisoned| poisoned.into_inner())
6944                .remove_unmutated(&lease.lease_id);
6945            if self.persist_state().is_err() {
6946                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6947            }
6948            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6949            self.remember_managed_spawn_attempt(
6950                durable_v2_key,
6951                request,
6952                result.clone(),
6953                accepted_at,
6954            );
6955            return result;
6956        }
6957        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6958            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6959            Err(error) => {
6960                self.reconcile_failed_allocation(&lease, &source_root).await;
6961                let result = Err(error);
6962                self.remember_managed_spawn_attempt(
6963                    durable_v2_key,
6964                    request,
6965                    result.clone(),
6966                    accepted_at,
6967                );
6968                return result;
6969            }
6970        };
6971        let created = match create_git_worktree_with_timeout(
6972                &source_root,
6973                &lease.target_root,
6974                &lease.branch,
6975                Some(&base_commit),
6976                mutation_timeout_ms,
6977            ).await {
6978            Ok(created) => created,
6979            Err(error) => {
6980                self.reconcile_failed_allocation(&lease, &source_root).await;
6981                let result = Err(if Instant::now() >= deadline
6982                    || error.message.contains("timed out")
6983                    || error.message.contains("deadline")
6984                {
6985                    failure(
6986                        NodeFailureCode::SpawnDeadlineExceeded,
6987                        "managed worktree creation exceeded the spawn deadline",
6988                    )
6989                } else {
6990                    managed_git_worktree_failure(error)
6991                });
6992                self.remember_managed_spawn_attempt(
6993                    durable_v2_key,
6994                    request,
6995                    result.clone(),
6996                    accepted_at,
6997                );
6998                return result;
6999            }
7000        };
7001        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7002            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7003            let result = Err(failure(
7004                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7005                "created worktree identity did not match its durable lease",
7006            ));
7007            self.remember_managed_spawn_attempt(
7008                durable_v2_key,
7009                request,
7010                result.clone(),
7011                accepted_at,
7012            );
7013            return result;
7014        }
7015        {
7016            let mut registry = self.managed_worktrees.lock()
7017                .unwrap_or_else(|poisoned| poisoned.into_inner());
7018            let current = registry.get_mut(&lease.lease_id)
7019                .expect("allocated managed worktree lease remains present");
7020            current.expected_head = Some(created.head.clone());
7021            current.state = ManagedWorktreeLeaseState::Ready;
7022            current.updated_at_unix_ms = unix_time_ms();
7023        }
7024        if let Err(error) = self.persist_state() {
7025            self.mark_managed_recovery_required(
7026                &lease.lease_id,
7027                ManagedWorktreeCleanupFailure::Backend,
7028            );
7029            let result = Err(persistence_failure(error));
7030            self.remember_managed_spawn_attempt(
7031                durable_v2_key,
7032                request,
7033                result.clone(),
7034                accepted_at,
7035            );
7036            return result;
7037        }
7038        let ready = self.managed_worktrees.lock()
7039            .unwrap_or_else(|poisoned| poisoned.into_inner())
7040            .get(&lease.lease_id)
7041            .expect("ready managed worktree lease remains present")
7042            .snapshot();
7043        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7044        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7045            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7046            let result = Err(error);
7047            self.remember_managed_spawn_attempt(
7048                durable_v2_key,
7049                request,
7050                result.clone(),
7051                accepted_at,
7052            );
7053            return result;
7054        }
7055        let spawn = self.spawn_session_with_deadline(
7056            lease.workspace_id.clone(),
7057            resolved.provider.clone(),
7058            resolved.mode,
7059            resolved.terminal_size,
7060            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7061            environment_profile.clone(),
7062            bundle.clone(),
7063            context.clone(),
7064            Some(lease.lease_id.clone()),
7065            Some(runtime_policy),
7066            SpawnRecordPolicy::Always,
7067            Some(deadline),
7068            &required_capabilities,
7069            None,
7070            resolved.approval_level,
7071            resolved.network_allowlist.clone(),
7072            resolved.browser_profile_id.clone(),
7073        ).await;
7074        let (session, effective_runtime_policy) = match spawn {
7075            Ok(spawned) => spawned,
7076            Err(error) => {
7077                if self
7078                    .cleanup_managed_worktree(&lease.lease_id, true)
7079                    .await
7080                    .is_err()
7081                {
7082                    self.mark_managed_recovery_required(
7083                        &lease.lease_id,
7084                        ManagedWorktreeCleanupFailure::Backend,
7085                    );
7086                }
7087                let result = Err(error);
7088                self.remember_managed_spawn_attempt(
7089                    durable_v2_key,
7090                    request,
7091                    result.clone(),
7092                    accepted_at,
7093                );
7094                return result;
7095            }
7096        };
7097        let (record_id, raw_inventory_record_id) = {
7098            let mut bindings = self.session_bindings.lock()
7099                .unwrap_or_else(|poisoned| poisoned.into_inner());
7100            let binding = bindings.get_mut(&session.session.instance_id)
7101                .expect("successful spawn retains its session binding");
7102            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7103            managed_spawn_record_ownership(binding, effective_runtime_policy)
7104        };
7105        let bound = self.managed_worktrees.lock()
7106            .unwrap_or_else(|poisoned| poisoned.into_inner())
7107            .bind_session(
7108                &lease.lease_id,
7109                ManagedWorktreeSessionHolder {
7110                    incarnation_id: self.incarnation_id,
7111                    instance_id: session.session.instance_id,
7112                    generation: session.session.generation,
7113                },
7114                record_id,
7115                unix_time_ms(),
7116            )
7117            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7118        let snapshot = match bound {
7119            Ok(snapshot) => snapshot,
7120            Err(error) => {
7121                if raw_inventory_record_id
7122                    .as_ref()
7123                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7124                {
7125                    self.mark_managed_recovery_required(
7126                        &lease.lease_id,
7127                        ManagedWorktreeCleanupFailure::Backend,
7128                    );
7129                }
7130                let _ = self.remove_session(&session).await;
7131                self.mark_managed_recovery_required(
7132                    &lease.lease_id,
7133                    ManagedWorktreeCleanupFailure::Backend,
7134                );
7135                let result = Err(error);
7136                self.remember_managed_spawn_attempt(
7137                    durable_v2_key,
7138                    request,
7139                    result.clone(),
7140                    accepted_at,
7141                );
7142                return result;
7143            }
7144        };
7145        if let Err(error) = self.persist_state() {
7146            if raw_inventory_record_id
7147                .as_ref()
7148                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7149            {
7150                self.mark_managed_recovery_required(
7151                    &lease.lease_id,
7152                    ManagedWorktreeCleanupFailure::Backend,
7153                );
7154            }
7155            let _ = self.remove_session(&session).await;
7156            if self
7157                .cleanup_managed_worktree(&lease.lease_id, true)
7158                .await
7159                .is_err()
7160            {
7161                self.mark_managed_recovery_required(
7162                    &lease.lease_id,
7163                    ManagedWorktreeCleanupFailure::Backend,
7164                );
7165            }
7166            let result = Err(persistence_failure(error));
7167            self.remember_managed_spawn_attempt(
7168                durable_v2_key,
7169                request,
7170                result.clone(),
7171                accepted_at,
7172            );
7173            return result;
7174        }
7175        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7176        let receipt = managed_spawn_receipt(
7177            &resolved,
7178            self.incarnation_id,
7179            session,
7180            snapshot,
7181            environment_profile,
7182            bundle,
7183            context,
7184        );
7185        let result = Ok(receipt);
7186        self.remember_managed_spawn_attempt(
7187            durable_v2_key,
7188            request,
7189            result.clone(),
7190            accepted_at,
7191        );
7192        result
7193    }
7194
7195    async fn spawn_managed_worktree_v2(
7196        &self,
7197        request: ManagedWorktreeSpawnRequestV2,
7198    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7199        if request.spawn_spec.target.node_id != self.node_id {
7200            return Err(failure(
7201                NodeFailureCode::SpawnTargetMismatch,
7202                "spawn target does not match this node",
7203            ));
7204        }
7205        if request.spawn_spec.target.worktree_id.is_some() {
7206            return Err(failure(
7207                NodeFailureCode::InvalidRequest,
7208                "managed worktree spawn must not select a caller-provided worktree",
7209            ));
7210        }
7211        let request_digest = managed_spawn_request_digest_v2(&request)?;
7212        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7213        match replay {
7214            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7215            DurableManagedSpawnReplayDecision::PendingLinked => {
7216                return Err(failure(
7217                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7218                    "managed worktree V2 reservation has an incomplete durable allocation",
7219                ));
7220            }
7221            DurableManagedSpawnReplayDecision::Reserved => {}
7222        }
7223        let validated = (|| {
7224            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7225            let profile = self.managed_profile(
7226                &resolved.target.workspace_id,
7227                &request.worktree_profile_id,
7228            )?;
7229            if profile.revision() != &request.expected_profile_revision {
7230                return Err(failure(
7231                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7232                    "managed worktree profile revision does not match the loaded profile",
7233                ));
7234            }
7235            Ok(())
7236        })();
7237        match validated {
7238            Ok(()) => {}
7239            Err(error) => {
7240                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7241                return Err(error);
7242            }
7243        }
7244        let legacy = ManagedWorktreeSpawnRequest {
7245            spawn_spec: request.spawn_spec.clone(),
7246            worktree_profile_id: request.worktree_profile_id.clone(),
7247        };
7248        match self
7249            .spawn_managed_worktree_inner(
7250                legacy,
7251                Some(&request.spawn_spec.idempotency_key),
7252            )
7253            .await
7254        {
7255            Ok(receipt) => {
7256                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7257                Ok(receipt)
7258            }
7259            Err(error) => {
7260                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7261                Err(error)
7262            }
7263        }
7264    }
7265
7266    fn managed_profile(
7267        &self,
7268        workspace_id: &WorkspaceId,
7269        profile_id: &WorktreeProfileId,
7270    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7271        if self.worktree_service_modes.get(workspace_id).copied()
7272            != Some(WorktreeServiceMode::Managed)
7273        {
7274            return Err(failure(
7275                NodeFailureCode::UnsupportedSpawnCapability,
7276                "workspace does not enable managed worktrees",
7277            ));
7278        }
7279        self.managed_worktree_profiles.get(workspace_id)
7280            .and_then(|profiles| profiles.get(profile_id))
7281            .cloned()
7282            .ok_or_else(|| failure(
7283                NodeFailureCode::InvalidRequest,
7284                "managed worktree profile is unavailable for this workspace",
7285            ))
7286    }
7287
7288    async fn reconcile_failed_allocation(
7289        &self,
7290        lease: &ManagedWorktreeLeaseRecord,
7291        source_root: &str,
7292    ) {
7293        match list_git_worktrees(source_root).await {
7294            Ok(worktrees) => {
7295                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7296                    None => {
7297                        match std::fs::symlink_metadata(&lease.target_root) {
7298                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7299                                self.managed_worktrees.lock()
7300                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7301                                    .remove_unmutated(&lease.lease_id);
7302                                let _ = self.persist_state();
7303                            }
7304                            _ => self.mark_managed_recovery_required(
7305                                &lease.lease_id,
7306                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7307                            ),
7308                        }
7309                    }
7310                    Some(item) if exact_owned_worktree(
7311                        lease,
7312                        &item.path,
7313                        item.branch.as_deref(),
7314                        &item.head,
7315                    ) => {
7316                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7317                    }
7318                    Some(_) => self.mark_managed_recovery_required(
7319                        &lease.lease_id,
7320                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7321                    ),
7322                }
7323            }
7324            Err(_) => self.mark_managed_recovery_required(
7325                &lease.lease_id,
7326                ManagedWorktreeCleanupFailure::Backend,
7327            ),
7328        }
7329    }
7330
7331    async fn reconcile_managed_worktrees(&self) {
7332        let leases = self.managed_worktrees.lock()
7333            .unwrap_or_else(|poisoned| poisoned.into_inner())
7334            .active_records()
7335            .cloned()
7336            .collect::<Vec<_>>();
7337        for lease in leases {
7338            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7339                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7340                _ => {
7341                    self.mark_managed_recovery_required(
7342                        &lease.lease_id,
7343                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7344                    );
7345                    continue;
7346                }
7347            };
7348            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7349                self.mark_managed_recovery_required(
7350                    &lease.lease_id,
7351                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7352                );
7353                continue;
7354            }
7355            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7356                Ok(root) => root,
7357                Err(_) => {
7358                    self.mark_managed_recovery_required(
7359                        &lease.lease_id,
7360                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7361                    );
7362                    continue;
7363                }
7364            };
7365            let worktrees = match list_git_worktrees(&source_root).await {
7366                Ok(worktrees) => worktrees,
7367                Err(_) => {
7368                    self.mark_managed_recovery_required(
7369                        &lease.lease_id,
7370                        ManagedWorktreeCleanupFailure::Backend,
7371                    );
7372                    continue;
7373                }
7374            };
7375            let listed = worktrees.iter()
7376                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7377            let Some(listed) = listed else {
7378                match std::fs::symlink_metadata(&lease.target_root) {
7379                    Err(error) if error.kind() == io::ErrorKind::NotFound
7380                        && lease.state == ManagedWorktreeLeaseState::Allocating
7381                        && !lease.has_holders() => {
7382                            let removed = self.managed_worktrees.lock()
7383                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7384                                .tombstone(&lease.lease_id, unix_time_ms());
7385                            if self.persist_state().is_ok() && removed.is_some() {
7386                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7387                                    lease_id: lease.lease_id.clone(),
7388                                });
7389                            }
7390                        }
7391                    _ => self.mark_managed_recovery_required(
7392                        &lease.lease_id,
7393                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7394                    ),
7395                }
7396                continue;
7397            };
7398            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7399                &lease,
7400                &listed.path,
7401                listed.branch.as_deref(),
7402                &listed.head,
7403            ) {
7404                self.mark_managed_recovery_required(
7405                    &lease.lease_id,
7406                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7407                );
7408                continue;
7409            }
7410            if !self.workspaces.read()
7411                .unwrap_or_else(|poisoned| poisoned.into_inner())
7412                .contains_key(&lease.workspace_id)
7413            {
7414                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7415                    self.mark_managed_recovery_required(
7416                        &lease.lease_id,
7417                        ManagedWorktreeCleanupFailure::Backend,
7418                    );
7419                    continue;
7420                }
7421            }
7422            let snapshot = {
7423                let mut registry = self.managed_worktrees.lock()
7424                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7425                let current = registry.get_mut(&lease.lease_id)
7426                    .expect("reconciled managed lease remains present");
7427                current.expected_head = Some(listed.head.clone());
7428                current.cleanup_failure = None;
7429                current.state = if current.has_holders() {
7430                    ManagedWorktreeLeaseState::InUse
7431                } else if current.retention == ManagedWorktreeRetention::Retain {
7432                    ManagedWorktreeLeaseState::Retained
7433                } else {
7434                    ManagedWorktreeLeaseState::Ready
7435                };
7436                current.updated_at_unix_ms = unix_time_ms();
7437                current.snapshot()
7438            };
7439            if self.persist_state().is_err() {
7440                self.mark_managed_recovery_required(
7441                    &lease.lease_id,
7442                    ManagedWorktreeCleanupFailure::Backend,
7443                );
7444                continue;
7445            }
7446            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7447            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7448                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7449            }
7450        }
7451    }
7452
7453    /// Crash-recovery startup sweep: durably capture the pack for every
7454    /// `Live` managed record whose most recent session has not yet produced
7455    /// one (recovers a session whose `Exited{0}` event was observed but the
7456    /// Node crashed before the export finished). The reactive fast path for
7457    /// a single just-exited record does not go through this — see
7458    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7459    /// it can capture the exact `SessionAddress` before `publish_control`
7460    /// clears it, rather than re-deriving it from (by then likely already
7461    /// `Dormant`) record state the way a rescan naturally would.
7462    ///
7463    /// Returns as soon as every eligible record's export has been *spawned*,
7464    /// not completed — each one runs on its own detached task (see
7465    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7466    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7467    /// point this is called from `NodeServer::run()`, so nothing could ever
7468    /// drain the history round trip if this awaited it inline here either).
7469    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7470    /// is a silent skip for THIS record — an unusable source or a racing
7471    /// record is not surfaced to any caller.
7472    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7473        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7474            .session_records
7475            .lock()
7476            .unwrap_or_else(|poisoned| poisoned.into_inner())
7477            .records
7478            .values()
7479            .filter(|record| {
7480                record.state == ManagedSessionState::Live
7481                    && record.exported_context.is_none()
7482                    && record.provider_session.is_some()
7483            })
7484            .filter_map(|record| {
7485                record
7486                    .active_session
7487                    .clone()
7488                    .map(|session| (record.record_id.clone(), session))
7489            })
7490            .collect();
7491        for (record_id, session) in candidates {
7492            let shared = Arc::clone(self);
7493            tokio::spawn(async move {
7494                shared
7495                    .reconcile_context_pack_export_for_record(&record_id, session)
7496                    .await;
7497            });
7498        }
7499    }
7500
7501    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7502    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7503    /// round trip this performs (`discover_history`/`load_history`, via
7504    /// `export_context_pack_for_session_record`) can only ever settle once
7505    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7506    /// completion — and `tick()` is exclusively owned by, and only ever
7507    /// called from, that same drive-loop task. Awaiting this inline there
7508    /// would starve `tick()` and this call could never observe its own
7509    /// command complete.
7510    ///
7511    /// Because this now genuinely races the drive loop's own
7512    /// `publish_control` (which downgrades this exact record from `Live` to
7513    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7514    /// triggered this reconcile), the record legitimately may already be
7515    /// `Dormant` by the time this reads it, or may transition mid-flight —
7516    /// `allow_clean_detachment: true` on the downstream export call is what
7517    /// tolerates exactly that transition (and only that transition; see
7518    /// `session_record_export_target_matches`).
7519    async fn reconcile_context_pack_export_for_record(
7520        &self,
7521        record_id: &SessionRecordId,
7522        session: SessionAddress,
7523    ) {
7524        let Ok(record) = self.record(record_id) else {
7525            return;
7526        };
7527        if !matches!(
7528            record.state,
7529            ManagedSessionState::Live | ManagedSessionState::Dormant
7530        ) || record.exported_context.is_some()
7531            || record.provider_session.is_none()
7532        {
7533            return;
7534        }
7535        // Pack bytes are already durably committed inside this call, via
7536        // commit_context_pack_for_session_record's own durable
7537        // write-through (context_pack_store) — no separate step here.
7538        // Unclean exits (non-zero/forced/no loadable history) are already
7539        // rejected by context_pack_source_status_is_usable inside
7540        // materialize_context_pack: this simply returns Err and is skipped.
7541        let Ok(receipt) = self
7542            .export_context_pack_for_session_record(&record.record_id, &session, true)
7543            .await
7544        else {
7545            return;
7546        };
7547        self.set_exported_context(&record.record_id, receipt);
7548    }
7549
7550    /// Records the record's most recent clean-exit pack. No-op (not an
7551    /// error) if the record disappeared, or if it already carries an
7552    /// `exported_context` — first-write-wins, matching the harness-side
7553    /// idempotent reconciliation that consumes this field.
7554    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7555        let transaction = self
7556            .state_transaction
7557            .lock()
7558            .unwrap_or_else(|poisoned| poisoned.into_inner());
7559        let (previous, updated) = {
7560            let mut records = self
7561                .session_records
7562                .lock()
7563                .unwrap_or_else(|poisoned| poisoned.into_inner());
7564            let Some(record) = records.records.get_mut(record_id) else {
7565                return;
7566            };
7567            if record.exported_context.is_some() {
7568                return;
7569            }
7570            let previous = record.clone();
7571            record.exported_context = Some(receipt);
7572            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7573            (previous, record.clone())
7574        };
7575        if self.persist_state_locked().is_err() {
7576            self.session_records
7577                .lock()
7578                .unwrap_or_else(|poisoned| poisoned.into_inner())
7579                .records
7580                .insert(record_id.clone(), previous);
7581            return;
7582        }
7583        drop(transaction);
7584        self.publish_record(updated);
7585    }
7586
7587    /// Resolves the managed record and its exact `SessionAddress` currently
7588    /// bound to `instance_id` at exactly `generation`, used by the reactive
7589    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7590    /// record without a full rescan. Mirrors the lookup already inlined in
7591    /// `publish_control`. Captured synchronously, before `publish_control`
7592    /// for the same event clears the record's own `active_session` — the
7593    /// caller passes the resolved address through to a detached
7594    /// reconciliation task rather than re-deriving it from the record later,
7595    /// after that race has necessarily already happened.
7596    fn managed_record_export_target_for_instance(
7597        &self,
7598        instance_id: AgentInstanceId,
7599        generation: SessionGeneration,
7600    ) -> Option<(SessionRecordId, SessionAddress)> {
7601        let bindings = self
7602            .session_bindings
7603            .lock()
7604            .unwrap_or_else(|poisoned| poisoned.into_inner());
7605        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7606        let record_id = binding.record_id.clone()?;
7607        Some((
7608            record_id,
7609            SessionAddress {
7610                workspace_id: binding.workspace_id.clone(),
7611                session: SessionKey { instance_id, generation },
7612            },
7613        ))
7614    }
7615
7616    fn mark_managed_recovery_required(
7617        &self,
7618        lease_id: &ManagedWorktreeLeaseId,
7619        failure_kind: ManagedWorktreeCleanupFailure,
7620    ) {
7621        let snapshot = {
7622            let mut registry = self.managed_worktrees.lock()
7623                .unwrap_or_else(|poisoned| poisoned.into_inner());
7624            let Some(lease) = registry.get_mut(lease_id) else { return };
7625            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7626            lease.cleanup_failure = Some(failure_kind);
7627            lease.updated_at_unix_ms = unix_time_ms();
7628            lease.snapshot()
7629        };
7630        let _ = self.persist_state();
7631        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7632    }
7633
7634    fn set_persistence_error(&self, error: Option<String>) {
7635        *self
7636            .persistence_error
7637            .write()
7638            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7639            .map(|message| session_registry::sanitized_persistence_summary(&message));
7640    }
7641
7642    fn persist_state(&self) -> io::Result<()> {
7643        let _transaction = self
7644            .state_transaction
7645            .lock()
7646            .unwrap_or_else(|poisoned| poisoned.into_inner());
7647        self.persist_state_locked()
7648    }
7649
7650    fn persist_state_locked(&self) -> io::Result<()> {
7651        let workspaces = self
7652            .workspaces
7653            .read()
7654            .unwrap_or_else(|poisoned| poisoned.into_inner())
7655            .clone();
7656        let records = self
7657            .session_records
7658            .lock()
7659            .unwrap_or_else(|poisoned| poisoned.into_inner())
7660            .records
7661            .values()
7662            .cloned()
7663            .collect::<Vec<_>>();
7664        let mut managed = self.managed_worktrees
7665            .lock()
7666            .unwrap_or_else(|poisoned| poisoned.into_inner());
7667        managed.reattach_record_holders(
7668            &managed_worktree_record_holders(&records),
7669            unix_time_ms(),
7670        );
7671        let materializations = self
7672            .materializations
7673            .lock()
7674            .unwrap_or_else(|poisoned| poisoned.into_inner())
7675            .values()
7676            .cloned()
7677            .collect::<Vec<_>>();
7678        let managed_spawn_replays = self.managed_spawn_replays
7679            .lock()
7680            .unwrap_or_else(|poisoned| poisoned.into_inner())
7681            .values()
7682            .cloned()
7683            .collect::<Vec<_>>();
7684        let result = session_registry::save_v10(
7685            self.state_path.as_deref(),
7686            &self.node_id,
7687            &workspaces,
7688            &records,
7689            &managed.records(),
7690            &managed.tombstones(),
7691            &materializations,
7692            &managed_spawn_replays,
7693        );
7694        match &result {
7695            Ok(warning) => self.set_persistence_error(warning.clone()),
7696            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7697        }
7698        result.map(|_| ())
7699    }
7700
7701    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7702        let _mutation_guard = self.mutation_gate.lock().await;
7703        self.begin_shutdown_locked().await
7704    }
7705
7706    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7707        if self.shutdown.swap(true, Ordering::AcqRel) {
7708            return Ok(());
7709        }
7710        SessionLifecycleHook::on_shutdown(self);
7711        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7712        let mut first_error = None;
7713        for session in &self.handle.snapshot().sessions {
7714            let remaining = deadline.saturating_duration_since(Instant::now());
7715            if remaining.is_zero() {
7716                first_error.get_or_insert_with(|| {
7717                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7718                });
7719                break;
7720            }
7721            if let Err(error) = self
7722                .dispatch_bounded(
7723                    ControlCommand::Stop {
7724                        instance_id: session.instance_id,
7725                        force: true,
7726                    },
7727                    remaining,
7728                )
7729                .await
7730            {
7731                first_error.get_or_insert(error);
7732            }
7733        }
7734        self.shutdown_notify.notify_waiters();
7735        match first_error {
7736            Some(error) => Err(error),
7737            None => Ok(()),
7738        }
7739    }
7740
7741    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7742        let bindings = self
7743            .session_bindings
7744            .lock()
7745            .unwrap_or_else(|poisoned| poisoned.into_inner());
7746        if bindings.len() >= CONTROL_SESSIONS_MAX {
7747            Err(failure(
7748                NodeFailureCode::BackendBusy,
7749                "node session-binding capacity is full; remove an existing session first",
7750            ))
7751        } else {
7752            Ok(())
7753        }
7754    }
7755
7756    /// One-owner workspace/env collision guard: a live session on
7757    /// `workspace_id` with a different `environment_profile` (option
7758    /// equality on the resolved receipt, including Some vs None) blocks
7759    /// another spawn on that same cwd. Same profile may still share the
7760    /// workspace; distinct profiles need distinct workspace ids (managed
7761    /// worktree).
7762    fn ensure_workspace_environment_profile_compatible(
7763        &self,
7764        workspace_id: &WorkspaceId,
7765        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7766    ) -> Result<(), NodeFailure> {
7767        let bindings = self
7768            .session_bindings
7769            .lock()
7770            .unwrap_or_else(|poisoned| poisoned.into_inner());
7771        for binding in bindings.values() {
7772            if &binding.workspace_id != workspace_id {
7773                continue;
7774            }
7775            if binding.environment_profile.as_ref() != environment_profile {
7776                return Err(failure(
7777                    NodeFailureCode::WorkspaceBusy,
7778                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7779                ));
7780            }
7781        }
7782        Ok(())
7783    }
7784
7785    #[cfg(test)]
7786    fn bind_session(
7787        &self,
7788        instance_id: AgentInstanceId,
7789        workspace_id: WorkspaceId,
7790        generation: SessionGeneration,
7791    ) {
7792        self.bind_session_with_policy(
7793            &SessionAddress {
7794                workspace_id,
7795                session: SessionKey { instance_id, generation },
7796            },
7797            ProviderRuntimePolicy::raw_pty(),
7798            None,
7799        );
7800    }
7801
7802    #[cfg(test)]
7803    fn bind_session_with_policy(
7804        &self,
7805        address: &SessionAddress,
7806        runtime_policy: ProviderRuntimePolicy,
7807        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7808    ) {
7809        self.bind_session_with_materialization(
7810            address,
7811            runtime_policy,
7812            environment_profile,
7813            None,
7814            None,
7815            None,
7816        );
7817    }
7818
7819    fn bind_session_with_materialization(
7820        &self,
7821        address: &SessionAddress,
7822        runtime_policy: ProviderRuntimePolicy,
7823        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7824        bundle: Option<ResolvedBundleReceipt>,
7825        context: Option<ResolvedContextPackReceipt>,
7826        materialization_id: Option<MaterializationId>,
7827    ) {
7828        let mut bindings = self
7829            .session_bindings
7830            .lock()
7831            .unwrap_or_else(|poisoned| poisoned.into_inner());
7832        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7833        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7834        bindings.insert(address.session.instance_id, SessionBinding {
7835            workspace_id: address.workspace_id.clone(),
7836            generation: address.session.generation,
7837            runtime_policy,
7838            pending_resume: None,
7839            record_id: None,
7840            managed_worktree_lease_id: None,
7841            environment_profile,
7842            bundle,
7843            context,
7844            materialization_id,
7845        });
7846    }
7847
7848    #[cfg(test)]
7849    fn bind_managed_session(
7850        &self,
7851        address: &SessionAddress,
7852        record_id: SessionRecordId,
7853        runtime_policy: ProviderRuntimePolicy,
7854        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7855    ) {
7856        self.bind_managed_session_with_materialization(
7857            address,
7858            record_id,
7859            runtime_policy,
7860            environment_profile,
7861            None,
7862            None,
7863            None,
7864        );
7865    }
7866
7867    fn bind_managed_session_with_materialization(
7868        &self,
7869        address: &SessionAddress,
7870        record_id: SessionRecordId,
7871        runtime_policy: ProviderRuntimePolicy,
7872        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7873        bundle: Option<ResolvedBundleReceipt>,
7874        context: Option<ResolvedContextPackReceipt>,
7875        materialization_id: Option<MaterializationId>,
7876    ) {
7877        let mut bindings = self
7878            .session_bindings
7879            .lock()
7880            .unwrap_or_else(|poisoned| poisoned.into_inner());
7881        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7882        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7883        bindings.insert(
7884            address.session.instance_id,
7885            SessionBinding {
7886                workspace_id: address.workspace_id.clone(),
7887                generation: address.session.generation,
7888                runtime_policy,
7889                pending_resume: None,
7890                record_id: Some(record_id),
7891                managed_worktree_lease_id: None,
7892                environment_profile,
7893                bundle,
7894                context,
7895                materialization_id,
7896            },
7897        );
7898    }
7899
7900    #[cfg(test)]
7901    fn bind_spawn_session(
7902        &self,
7903        address: &SessionAddress,
7904        provider: AgentId,
7905        mode: SessionMode,
7906        runtime_policy: ProviderRuntimePolicy,
7907        record_policy: SpawnRecordPolicy,
7908        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7909    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7910        self.bind_spawn_session_with_materialization(
7911            address,
7912            provider,
7913            mode,
7914            runtime_policy,
7915            record_policy,
7916            environment_profile,
7917            None,
7918            None,
7919            None,
7920        )
7921    }
7922
7923    fn bind_spawn_session_with_materialization(
7924        &self,
7925        address: &SessionAddress,
7926        provider: AgentId,
7927        mode: SessionMode,
7928        runtime_policy: ProviderRuntimePolicy,
7929        record_policy: SpawnRecordPolicy,
7930        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7931        bundle: Option<ResolvedBundleReceipt>,
7932        context: Option<ResolvedContextPackReceipt>,
7933        materialization_id: Option<MaterializationId>,
7934    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7935        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7936            && !runtime_policy.provider_session_identity
7937        {
7938            self.bind_session_with_materialization(
7939                address,
7940                runtime_policy,
7941                environment_profile,
7942                bundle,
7943                context,
7944                materialization_id,
7945            );
7946            return Ok(None);
7947        }
7948        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7949        let record = self.new_record(
7950            address,
7951            provider,
7952            mode,
7953            active_record_state(runtime_policy.provider_session_identity, false),
7954            None,
7955            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7956            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7957            record_owns_lifecycle.then(|| context.clone()).flatten(),
7958        )?;
7959        let record_id = record.record_id.clone();
7960        let transaction = self
7961            .state_transaction
7962            .lock()
7963            .unwrap_or_else(|poisoned| poisoned.into_inner());
7964        self.insert_record(record.clone())?;
7965        let previous_materialization = if record_owns_lifecycle {
7966            if let Some(materialization_id) = materialization_id.as_ref() {
7967                let mut materializations = self
7968                    .materializations
7969                    .lock()
7970                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7971                let Some(ownership) = materializations.get_mut(materialization_id) else {
7972                    drop(materializations);
7973                    self.remove_record_memory(&record_id);
7974                    return Err(failure(
7975                        NodeFailureCode::BackendOperationFailed,
7976                        "session environment ownership disappeared before binding",
7977                    ));
7978                };
7979                let previous = ownership.clone();
7980                if ownership
7981                    .transfer_to_record(record_id.clone(), unix_time_ms())
7982                    .is_err()
7983                {
7984                    drop(materializations);
7985                    self.remove_record_memory(&record_id);
7986                    return Err(failure(
7987                        NodeFailureCode::BackendOperationFailed,
7988                        "session environment ownership transfer failed",
7989                    ));
7990                }
7991                Some(previous)
7992            } else {
7993                None
7994            }
7995        } else {
7996            None
7997        };
7998        self.bind_managed_session_with_materialization(
7999            address,
8000            record_id.clone(),
8001            runtime_policy,
8002            environment_profile,
8003            bundle,
8004            context,
8005            materialization_id.clone(),
8006        );
8007        if let Err(error) = self.persist_state_locked() {
8008            self.remove_binding(address);
8009            self.remove_record_memory(&record_id);
8010            if let (Some(materialization_id), Some(previous)) =
8011                (materialization_id.as_ref(), previous_materialization)
8012            {
8013                self.materializations
8014                    .lock()
8015                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8016                    .insert(materialization_id.clone(), previous);
8017            }
8018            return Err(persistence_failure(error));
8019        }
8020        drop(transaction);
8021        self.publish_record(record);
8022        Ok(Some(record_id))
8023    }
8024
8025    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8026        for _ in 0..8 {
8027            let bytes = random_nonce().map_err(|error| {
8028                failure(
8029                    NodeFailureCode::BackendOperationFailed,
8030                    &format!("session record identity generation failed: {error}"),
8031                )
8032            })?;
8033            let mut value = String::with_capacity(27);
8034            value.push_str("sr-");
8035            for byte in &bytes[..12] {
8036                use std::fmt::Write as _;
8037                let _ = write!(&mut value, "{byte:02x}");
8038            }
8039            let record_id = SessionRecordId::new(value).map_err(|error| {
8040                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8041            })?;
8042            let records = self
8043                .session_records
8044                .lock()
8045                .unwrap_or_else(|poisoned| poisoned.into_inner());
8046            if !records.records.contains_key(&record_id) {
8047                return Ok(record_id);
8048            }
8049        }
8050        Err(failure(
8051            NodeFailureCode::BackendBusy,
8052            "could not allocate a unique session record ID",
8053        ))
8054    }
8055
8056    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8057        for _ in 0..8 {
8058            let bytes = random_nonce().map_err(|error| {
8059                failure(
8060                    NodeFailureCode::BackendOperationFailed,
8061                    &format!("task identity generation failed: {error}"),
8062                )
8063            })?;
8064            let mut nonce = [0_u8; 12];
8065            nonce.copy_from_slice(&bytes[..12]);
8066            let task_id = TaskId::from_nonce(nonce);
8067            let records = self
8068                .session_records
8069                .lock()
8070                .unwrap_or_else(|poisoned| poisoned.into_inner());
8071            if !records.records.values().any(|record| {
8072                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8073                    == Some(&task_id)
8074            }) {
8075                return Ok(task_id);
8076            }
8077        }
8078        Err(failure(
8079            NodeFailureCode::BackendBusy,
8080            "could not allocate a unique task ID",
8081        ))
8082    }
8083
8084    fn default_record_name(&self, provider: &AgentId) -> String {
8085        let records = self
8086            .session_records
8087            .lock()
8088            .unwrap_or_else(|poisoned| poisoned.into_inner());
8089        let ordinal = records
8090            .records
8091            .values()
8092            .filter(|record| &record.provider == provider)
8093            .count()
8094            .saturating_add(1);
8095        format!("{} #{ordinal}", provider.as_str())
8096    }
8097
8098    fn new_record(
8099        &self,
8100        address: &SessionAddress,
8101        provider: AgentId,
8102        mode: SessionMode,
8103        state: ManagedSessionState,
8104        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8105        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8106        bundle: Option<ResolvedBundleReceipt>,
8107        context: Option<ResolvedContextPackReceipt>,
8108    ) -> Result<ManagedSessionRecord, NodeFailure> {
8109        let canonical_root = self.workspace_root(&address.workspace_id)?;
8110        let now = unix_time_ms();
8111        Ok(ManagedSessionRecord {
8112            record_id: self.allocate_record_id()?,
8113            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8114            provider,
8115            mode,
8116            state,
8117            workspace_id: address.workspace_id.clone(),
8118            canonical_root: opaque_windows_path(canonical_root),
8119            provider_session,
8120            active_session: Some(address.clone()),
8121            environment_profile,
8122            bundle,
8123            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8124            context,
8125            exported_context: None,
8126            task_binding: None,
8127            created_at_unix_ms: now,
8128            updated_at_unix_ms: now,
8129            last_error: None,
8130        })
8131    }
8132
8133    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8134        let mut records = self
8135            .session_records
8136            .lock()
8137            .unwrap_or_else(|poisoned| poisoned.into_inner());
8138        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8139            return Err(failure(
8140                NodeFailureCode::BackendBusy,
8141                "managed session record capacity is full; forget an inactive record first",
8142            ));
8143        }
8144        if let Some(identity) = record.provider_session.as_ref() {
8145            if records.records.values().any(|candidate| {
8146                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8147                    session_registry::same_provider_session(
8148                        &candidate.provider,
8149                        candidate_identity,
8150                        &record.provider,
8151                        identity,
8152                    )
8153                })
8154            }) {
8155                return Err(failure(
8156                    NodeFailureCode::SessionRecordConflict,
8157                    "provider session is already represented by another managed record",
8158                ));
8159            }
8160        }
8161        match records.records.entry(record.record_id.clone()) {
8162            std::collections::btree_map::Entry::Vacant(entry) => {
8163                entry.insert(record);
8164            }
8165            std::collections::btree_map::Entry::Occupied(_) => {
8166                return Err(failure(
8167                    NodeFailureCode::SessionRecordConflict,
8168                    "session record ID collision",
8169                ));
8170            }
8171        }
8172        Ok(())
8173    }
8174
8175    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8176        self.session_records
8177            .lock()
8178            .unwrap_or_else(|poisoned| poisoned.into_inner())
8179            .records
8180            .get(record_id)
8181            .cloned()
8182            .ok_or_else(|| {
8183                failure(
8184                    NodeFailureCode::UnknownSessionRecord,
8185                    "managed session record does not exist",
8186                )
8187            })
8188    }
8189
8190    fn revalidate_session_record_preview(
8191        &self,
8192        expected: &ManagedSessionRecord,
8193        identity: &ProviderSessionIdentity,
8194    ) -> Result<(), NodeFailure> {
8195        let current = self.record(&expected.record_id)?;
8196        let current_root = self.workspace_root(&current.workspace_id)?;
8197        if current.provider != expected.provider
8198            || current.workspace_id != expected.workspace_id
8199            || current.canonical_root != expected.canonical_root
8200            || current.provider_session.as_ref() != Some(identity)
8201            || !platform::roots_equal(
8202                &current_root,
8203                windows_path_text(&current.canonical_root),
8204            )
8205        {
8206            return Err(failure(
8207                NodeFailureCode::SessionRecordConflict,
8208                "managed session changed while its preview was loading",
8209            ));
8210        }
8211        Ok(())
8212    }
8213
8214    fn revalidate_session_record_context_export(
8215        &self,
8216        expected: &ManagedSessionRecord,
8217        identity: Option<&ProviderSessionIdentity>,
8218        session: &SessionAddress,
8219        allow_clean_detachment: bool,
8220    ) -> Result<(), NodeFailure> {
8221        let current = self.record(&expected.record_id)?;
8222        let current_root = self.workspace_root(&current.workspace_id)?;
8223        let snapshot = self.internal_session_snapshot(session)?;
8224        let binding = self
8225            .session_bindings
8226            .lock()
8227            .unwrap_or_else(|poisoned| poisoned.into_inner())
8228            .get(&session.session.instance_id)
8229            .cloned()
8230            .ok_or_else(|| {
8231                failure(
8232                    NodeFailureCode::SessionRecordConflict,
8233                    "managed session runtime binding disappeared during context export",
8234                )
8235            })?;
8236        if !session_record_context_export_binding_is_exact(
8237            expected,
8238            &current,
8239            identity,
8240            session,
8241            &binding,
8242            &snapshot.agent_id,
8243            &current_root,
8244            allow_clean_detachment,
8245        ) || !session_record_context_export_source_is_usable(
8246            &current,
8247            &snapshot.status,
8248            allow_clean_detachment,
8249        ) {
8250            return Err(failure(
8251                NodeFailureCode::SessionRecordConflict,
8252                "managed session binding changed during context export",
8253            ));
8254        }
8255        Ok(())
8256    }
8257
8258    fn revalidate_loaded_history(
8259        &self,
8260        session: &SessionAddress,
8261        candidate_id: &str,
8262        loaded: &HistorySessionRecord,
8263    ) -> Result<(), NodeFailure> {
8264        let snapshot = self.internal_session_snapshot(session)?;
8265        if snapshot.history.pending.is_some()
8266            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8267            || snapshot.history.loaded.as_ref() != Some(loaded)
8268        {
8269            return Err(failure(
8270                NodeFailureCode::SessionRecordConflict,
8271                "managed session history changed during context export",
8272            ));
8273        }
8274        Ok(())
8275    }
8276
8277    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8278        self.session_records
8279            .lock()
8280            .unwrap_or_else(|poisoned| poisoned.into_inner())
8281            .records
8282            .remove(record_id)
8283    }
8284
8285    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8286        if self.destroy_record_materialization(record_id)? {
8287            self.publish(NodeEvent::SessionRecordRemoved {
8288                record_id: record_id.clone(),
8289            });
8290            return Ok(());
8291        }
8292        let transaction = self
8293            .state_transaction
8294            .lock()
8295            .unwrap_or_else(|poisoned| poisoned.into_inner());
8296        let Some(removed) = self.remove_record_memory(record_id) else {
8297            return Ok(());
8298        };
8299        if let Err(error) = self.persist_state_locked() {
8300            let _ = self.insert_record(removed);
8301            return Err(persistence_failure(error));
8302        }
8303        drop(transaction);
8304        self.publish(NodeEvent::SessionRecordRemoved {
8305            record_id: record_id.clone(),
8306        });
8307        Ok(())
8308    }
8309
8310    fn publish_record(&self, record: ManagedSessionRecord) {
8311        self.publish(NodeEvent::SessionRecordUpserted { record });
8312    }
8313
8314    fn mark_record_error(
8315        &self,
8316        record_id: &SessionRecordId,
8317        message: &str,
8318    ) -> Result<(), NodeFailure> {
8319        let transaction = self
8320            .state_transaction
8321            .lock()
8322            .unwrap_or_else(|poisoned| poisoned.into_inner());
8323        let previous = {
8324            let mut records = self
8325                .session_records
8326                .lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner());
8328            let Some(record) = records.records.get_mut(record_id) else {
8329                return Ok(());
8330            };
8331            let previous = record.clone();
8332            record.state = ManagedSessionState::Unavailable;
8333            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8334            record.updated_at_unix_ms = unix_time_ms();
8335            previous
8336        };
8337        if let Err(error) = self.persist_state_locked() {
8338            self.session_records
8339                .lock()
8340                .unwrap_or_else(|poisoned| poisoned.into_inner())
8341                .records
8342                .insert(record_id.clone(), previous);
8343            return Err(persistence_failure(error));
8344        }
8345        let record = self.record(record_id)?;
8346        drop(transaction);
8347        self.publish_record(record);
8348        Ok(())
8349    }
8350
8351    fn rename_session_record(
8352        &self,
8353        record_id: &SessionRecordId,
8354        display_name: String,
8355    ) -> Result<ManagedSessionRecord, NodeFailure> {
8356        validate_display_name(&display_name)
8357            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8358        let _transaction = self
8359            .state_transaction
8360            .lock()
8361            .unwrap_or_else(|poisoned| poisoned.into_inner());
8362        let previous = {
8363            let mut records = self
8364                .session_records
8365                .lock()
8366                .unwrap_or_else(|poisoned| poisoned.into_inner());
8367            let record = records.records.get_mut(record_id).ok_or_else(|| {
8368                failure(
8369                    NodeFailureCode::UnknownSessionRecord,
8370                    "managed session record does not exist",
8371                )
8372            })?;
8373            let previous = record.clone();
8374            record.display_name = display_name;
8375            record.updated_at_unix_ms = unix_time_ms();
8376            previous
8377        };
8378        if let Err(error) = self.persist_state_locked() {
8379            self.session_records
8380                .lock()
8381                .unwrap_or_else(|poisoned| poisoned.into_inner())
8382                .records
8383                .insert(record_id.clone(), previous);
8384            return Err(persistence_failure(error));
8385        }
8386        let updated = self.record(record_id)?;
8387        self.publish_record(updated.clone());
8388        Ok(updated)
8389    }
8390
8391    fn set_session_task(
8392        &self,
8393        record_id: &SessionRecordId,
8394        expected_revision: u64,
8395        target: SessionTaskTargetV1,
8396    ) -> Result<ManagedSessionRecord, NodeFailure> {
8397        let transaction = self
8398            .state_transaction
8399            .lock()
8400            .unwrap_or_else(|poisoned| poisoned.into_inner());
8401        let current = self.record(record_id)?;
8402        let current_task_id = current
8403            .task_binding
8404            .as_ref()
8405            .and_then(|binding| binding.task_id.as_ref());
8406        let current_revision = current
8407            .task_binding
8408            .as_ref()
8409            .map_or(0, |binding| binding.revision);
8410        if expected_revision != current_revision {
8411            return Err(failure(
8412                NodeFailureCode::SessionRecordConflict,
8413                "managed session task binding revision changed",
8414            ));
8415        }
8416        let exact = match &target {
8417            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8418            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8419            SessionTaskTargetV1::New => false,
8420        };
8421        if exact {
8422            return Ok(current);
8423        }
8424        let revision = current_revision.checked_add(1).ok_or_else(|| {
8425            failure(
8426                NodeFailureCode::SessionRecordConflict,
8427                "managed session task binding revision is exhausted",
8428            )
8429        })?;
8430        let task_id = match target {
8431            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8432            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8433            SessionTaskTargetV1::Clear => None,
8434        };
8435        let changed_at_unix_ms = unix_time_ms()
8436            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8437                failure(
8438                    NodeFailureCode::SessionRecordConflict,
8439                    "managed session record timestamp is exhausted",
8440                )
8441            })?);
8442        let updated = {
8443            let mut records = self
8444                .session_records
8445                .lock()
8446                .unwrap_or_else(|poisoned| poisoned.into_inner());
8447            let record = records.records.get_mut(record_id).ok_or_else(|| {
8448                failure(
8449                    NodeFailureCode::UnknownSessionRecord,
8450                    "managed session record does not exist",
8451                )
8452            })?;
8453            record.task_binding = Some(SessionTaskBindingV1 {
8454                revision,
8455                task_id,
8456                changed_at_unix_ms,
8457            });
8458            record.updated_at_unix_ms = changed_at_unix_ms;
8459            record.clone()
8460        };
8461        if let Err(error) = self.persist_state_locked() {
8462            self.session_records
8463                .lock()
8464                .unwrap_or_else(|poisoned| poisoned.into_inner())
8465                .records
8466                .insert(record_id.clone(), current);
8467            return Err(persistence_failure(error));
8468        }
8469        drop(transaction);
8470        self.publish_record(updated.clone());
8471        Ok(updated)
8472    }
8473
8474    fn index_provider_session(
8475        &self,
8476        workspace_id: WorkspaceId,
8477        provider: AgentId,
8478        identity: ProviderSessionIdentity,
8479        display_name: String,
8480    ) -> Result<ManagedSessionRecord, NodeFailure> {
8481        self.index_provider_session_with_policy(
8482            workspace_id,
8483            provider,
8484            identity,
8485            display_name,
8486            false,
8487        )
8488    }
8489
8490    fn index_provider_session_with_policy(
8491        &self,
8492        workspace_id: WorkspaceId,
8493        provider: AgentId,
8494        identity: ProviderSessionIdentity,
8495        display_name: String,
8496        allow_validated_transcript_path: bool,
8497    ) -> Result<ManagedSessionRecord, NodeFailure> {
8498        validate_display_name(&display_name)
8499            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8500        identity
8501            .validate()
8502            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8503        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8504            return Err(failure(
8505                NodeFailureCode::InvalidRequest,
8506                "provider session reference index v1 does not accept transcript paths",
8507            ));
8508        }
8509        let canonical_root = self.workspace_root(&workspace_id)?;
8510        if !self.enabled_providers.contains(&provider) {
8511            return Err(failure(
8512                NodeFailureCode::InvalidRequest,
8513                "provider is not enabled on this node",
8514            ));
8515        }
8516
8517        let transaction = self
8518            .state_transaction
8519            .lock()
8520            .unwrap_or_else(|poisoned| poisoned.into_inner());
8521        {
8522            let records = self
8523                .session_records
8524                .lock()
8525                .unwrap_or_else(|poisoned| poisoned.into_inner());
8526            if let Some(existing) = records.records.values().find(|candidate| {
8527                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8528                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8529                })
8530            }) {
8531                if existing.provider == provider && existing.workspace_id == workspace_id {
8532                    return Ok(existing.clone());
8533                }
8534                return Err(failure(
8535                    NodeFailureCode::SessionRecordConflict,
8536                    "provider session reference conflicts with another provider or workspace",
8537                ));
8538            }
8539            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8540                return Err(failure(
8541                    NodeFailureCode::BackendBusy,
8542                    "managed session record capacity is full; forget an inactive record first",
8543                ));
8544            }
8545        }
8546
8547        let now = unix_time_ms();
8548        let record = ManagedSessionRecord {
8549            record_id: self.allocate_record_id()?,
8550            display_name,
8551            provider,
8552            mode: SessionMode::Pty,
8553            state: ManagedSessionState::Dormant,
8554            workspace_id,
8555            canonical_root: opaque_windows_path(canonical_root),
8556            provider_session: Some(identity),
8557            active_session: None,
8558            environment_profile: None,
8559            bundle: None,
8560            context_id: None,
8561            context: None,
8562            exported_context: None,
8563            task_binding: None,
8564            created_at_unix_ms: now,
8565            updated_at_unix_ms: now,
8566            last_error: None,
8567        };
8568        self.insert_record(record.clone())?;
8569        if let Err(error) = self.persist_state_locked() {
8570            self.remove_record_memory(&record.record_id);
8571            return Err(persistence_failure(error));
8572        }
8573        drop(transaction);
8574        self.publish_record(record.clone());
8575        Ok(record)
8576    }
8577
8578    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8579        self.session_bindings
8580            .lock()
8581            .unwrap_or_else(|poisoned| poisoned.into_inner())
8582            .iter()
8583            .find_map(|(instance_id, binding)| {
8584                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8585                    workspace_id: binding.workspace_id.clone(),
8586                    session: SessionKey {
8587                        instance_id: *instance_id,
8588                        generation: binding.generation,
8589                    },
8590                })
8591            })
8592    }
8593
8594    async fn forget_session_record(
8595        &self,
8596        record_id: &SessionRecordId,
8597    ) -> Result<(), NodeFailure> {
8598        let record = self.record(record_id)?;
8599        if record.active_session.is_some()
8600            || matches!(
8601                record.state,
8602                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8603            )
8604        {
8605            return Err(failure(
8606                NodeFailureCode::SessionRecordBusy,
8607                "stop the managed session before forgetting it",
8608            ));
8609        }
8610        if let Some(address) = self.bound_address_for_record(record_id) {
8611            self.remove_session(&address).await?;
8612        }
8613        self.remove_managed_session_record(record_id, &record.workspace_id).await
8614    }
8615
8616    /// Removes one managed session record from durable state and notifies
8617    /// every listener -- the shared transaction body behind both
8618    /// `forget_session_record`'s operator-driven path (busy-checked above
8619    /// it, and clears any still-live session binding before calling in
8620    /// here) and the retention sweep (`retire_unavailable_session_records`,
8621    /// which only ever selects `ManagedSessionState::Unavailable` records
8622    /// that are already known never-busy and never bound, so it calls
8623    /// straight into this with neither check). Cleans a managed-worktree
8624    /// lease if the record held one, removes the record's environment
8625    /// materialization (or, absent one, removes it from memory directly),
8626    /// persists under `state_transaction` with rollback on persist failure,
8627    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8628    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8629    /// exactly as it does for an operator-driven forget.
8630    async fn remove_managed_session_record(
8631        &self,
8632        record_id: &SessionRecordId,
8633        workspace_id: &WorkspaceId,
8634    ) -> Result<(), NodeFailure> {
8635        let managed_lease_id = self.managed_worktrees.lock()
8636            .unwrap_or_else(|poisoned| poisoned.into_inner())
8637            .lease_for_workspace(workspace_id);
8638        if !self.destroy_record_materialization(record_id)? {
8639            let transaction = self
8640                .state_transaction
8641                .lock()
8642                .unwrap_or_else(|poisoned| poisoned.into_inner());
8643            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8644                failure(
8645                    NodeFailureCode::UnknownSessionRecord,
8646                    "managed session record does not exist",
8647                )
8648            })?;
8649            if let Err(error) = self.persist_state_locked() {
8650                let _ = self.insert_record(removed);
8651                return Err(persistence_failure(error));
8652            }
8653            drop(transaction);
8654        }
8655        self.publish(NodeEvent::SessionRecordRemoved {
8656            record_id: record_id.clone(),
8657        });
8658        if let Some(lease_id) = managed_lease_id {
8659            if let Some(snapshot) = self.managed_worktrees.lock()
8660                .unwrap_or_else(|poisoned| poisoned.into_inner())
8661                .get(&lease_id)
8662                .map(ManagedWorktreeLeaseRecord::snapshot)
8663            {
8664                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8665            }
8666            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8667        }
8668        Ok(())
8669    }
8670
8671    /// Retires `ManagedSessionState::Unavailable` records against the
8672    /// node's configured `session_record_retention` age/keep-N policy.
8673    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8674    /// `Dormant` is resumable and folding it into this sweep would delete
8675    /// records a caller can still act on. Both policy fields default to
8676    /// `0` (disabled); when both are `0` this is a no-op. When both are
8677    /// set, a record is retired only if it fails BOTH tests (older than
8678    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8679    /// workspace); when only one is set, that one alone decides. Called
8680    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8681    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8682    /// loop -- see both call sites.
8683    async fn retire_unavailable_session_records(&self) {
8684        let policy = self.session_record_retention;
8685        let age_enabled = policy.age_ms > 0;
8686        let keep_enabled = policy.keep_per_workspace > 0;
8687        if !age_enabled && !keep_enabled {
8688            return;
8689        }
8690        let now = unix_time_ms();
8691        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8692            self.session_records
8693                .lock()
8694                .unwrap_or_else(|poisoned| poisoned.into_inner())
8695                .records
8696                .values()
8697                .filter(|record| record.state == ManagedSessionState::Unavailable)
8698                .map(|record| {
8699                    (
8700                        record.record_id.clone(),
8701                        record.workspace_id.clone(),
8702                        record.provider.clone(),
8703                        record.updated_at_unix_ms,
8704                    )
8705                })
8706                .collect()
8707        };
8708        if candidates.is_empty() {
8709            return;
8710        }
8711        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8712            BTreeMap::new();
8713        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8714            by_workspace
8715                .entry(workspace_id)
8716                .or_default()
8717                .push((record_id, provider, updated_at_unix_ms));
8718        }
8719        let reason = if age_enabled && keep_enabled {
8720            "age+keep-n"
8721        } else if age_enabled {
8722            "age"
8723        } else {
8724            "keep-n"
8725        };
8726        let mut retired: u64 = 0;
8727        for (workspace_id, mut records) in by_workspace {
8728            // Newest `updated_at_unix_ms` first, so index `>=
8729            // keep_per_workspace` names exactly "beyond the newest N".
8730            records.sort_by(|left, right| right.2.cmp(&left.2));
8731            for (index, (record_id, provider, updated_at_unix_ms)) in
8732                records.into_iter().enumerate()
8733            {
8734                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8735                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8736                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8737                let should_retire = if age_enabled && keep_enabled {
8738                    age_hit && keep_hit
8739                } else {
8740                    age_hit || keep_hit
8741                };
8742                if !should_retire {
8743                    continue;
8744                }
8745                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8746                    Ok(()) => {
8747                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8748                        retired += 1;
8749                        tracing::info!(
8750                            node_id = %self.node_id,
8751                            record_id = %record_id,
8752                            workspace_id = %workspace_id,
8753                            provider = %provider,
8754                            age_ms = record_age_ms,
8755                            reason,
8756                            "managed session record retired"
8757                        );
8758                    }
8759                    Err(error) => tracing::warn!(
8760                        node_id = %self.node_id,
8761                        record_id = %record_id,
8762                        workspace_id = %workspace_id,
8763                        code = ?error.code,
8764                        cause = %error.message,
8765                        "session record retention sweep failed to retire a record"
8766                    ),
8767                }
8768            }
8769        }
8770        if retired > 0 {
8771            let remaining_unavailable = self
8772                .session_records
8773                .lock()
8774                .unwrap_or_else(|poisoned| poisoned.into_inner())
8775                .records
8776                .values()
8777                .filter(|record| record.state == ManagedSessionState::Unavailable)
8778                .count();
8779            tracing::info!(
8780                node_id = %self.node_id,
8781                retired,
8782                remaining_unavailable,
8783                "session record retention sweep completed"
8784            );
8785        }
8786    }
8787
8788    async fn resume_session_record(
8789        &self,
8790        record_id: &SessionRecordId,
8791        terminal_size: gate4agent_types::TerminalSize,
8792        initial_prompt: Option<String>,
8793    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8794        let mut record = self.record(record_id)?;
8795        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8796        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8797        if record.mode == SessionMode::Acp
8798            && record.provider.as_str() == "claude"
8799            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8800        {
8801            record.mode = SessionMode::Pty;
8802            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8803                r.mode = SessionMode::Pty;
8804            }
8805            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8806        }
8807        if record.active_session.is_some()
8808            || matches!(
8809                record.state,
8810                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8811            )
8812        {
8813            return Err(failure(
8814                NodeFailureCode::SessionRecordBusy,
8815                "managed session already has a live runtime binding",
8816            ));
8817        }
8818        let identity = record.provider_session.clone().ok_or_else(|| {
8819            failure(
8820                NodeFailureCode::SessionRecordNotResumable,
8821                "managed session has no verified provider session identity",
8822            )
8823        })?;
8824        let working_directory = self.workspace_root(&record.workspace_id)?;
8825        if !platform::roots_equal(
8826            &working_directory,
8827            windows_path_text(&record.canonical_root),
8828        ) {
8829            return Err(failure(
8830                NodeFailureCode::SessionRecordConflict,
8831                "managed session workspace root changed; refusing to resume in another directory",
8832            ));
8833        }
8834        {
8835            let records = self
8836                .session_records
8837                .lock()
8838                .unwrap_or_else(|poisoned| poisoned.into_inner());
8839            if records.records.values().any(|candidate| {
8840                candidate.record_id != *record_id
8841                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8842                        session_registry::same_provider_session(
8843                            &candidate.provider,
8844                            candidate_identity,
8845                            &record.provider,
8846                            &identity,
8847                        )
8848                    })
8849                    && (candidate.active_session.is_some()
8850                        || matches!(
8851                            candidate.state,
8852                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8853                        ))
8854            }) {
8855                return Err(failure(
8856                    NodeFailureCode::SessionRecordConflict,
8857                    "provider session is already active through another managed record",
8858                ));
8859            }
8860        }
8861        let runtime_requirement = if initial_prompt.is_some() {
8862            ProviderRuntimeRequirement::ResumeWithPrompt
8863        } else {
8864            ProviderRuntimeRequirement::Resume
8865        };
8866        let runtime_policy = self.admit_provider_runtime(
8867            &record.provider,
8868            runtime_requirement,
8869        )
8870        .await?;
8871        // The resumed runtime runs in the session folder its record was named after.
8872        let working_directory = match self.m4a_accounts.as_ref() {
8873            Some(_) => {
8874                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8875                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8876            }
8877            None => working_directory,
8878        };
8879        let request = ResumeLaunchRequest {
8880            working_directory,
8881            terminal_size,
8882            initial_prompt,
8883        };
8884        request
8885            .validate()
8886            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8887        self.ensure_binding_capacity()?;
8888        let resolved_materialization = match self.resolve_record_materialization(
8889            record_id,
8890            &record.provider,
8891            record.mode,
8892            record.environment_profile.as_ref(),
8893            record.bundle.as_ref(),
8894            record.context.as_ref(),
8895        ) {
8896            Ok(materialization) => materialization,
8897            Err(error) => {
8898                let label = match error.code {
8899                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8900                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8901                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8902                    _ => "environment-profile-unavailable",
8903                };
8904                self.mark_record_error(record_id, label)?;
8905                return Err(error);
8906            }
8907        };
8908        let (materialization_id, environment_overlay) = match resolved_materialization {
8909            Some((id, overlay)) => (Some(id), overlay),
8910            None => (None, None),
8911        };
8912        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8913        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8914        let environment_overlay = {
8915            let mut args: Vec<OsString> = Vec::new();
8916            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8917                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8918                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8919                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8920            }
8921            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8922            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8923            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8924                if let Some(sel) = options.as_ref() {
8925                    args.push("--model".into()); args.push(sel.model.clone().into());
8926                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8927                        args.push("--effort".into()); args.push(effort.to_owned().into());
8928                    }
8929                }
8930            }
8931            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8932                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8933                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8934                        instance_id: instance_id.0,
8935                        provider: record.provider.as_str().to_owned(),
8936                        workspace_id: record.workspace_id.as_str().to_owned(),
8937                        provider_session_id: identity.id.clone(),
8938                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8939                        cwd: String::new(),
8940                        acp: record.mode == SessionMode::Acp,
8941                        options,
8942                        cols: 0,
8943                        rows: 0,
8944                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8945                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8946                    });
8947                }
8948            }
8949            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8950        };
8951        // The resumed runtime keeps the m4a account of the runtime it replaces.
8952        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8953            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8954                if let Some(old) = self.bound_address_for_record(record_id) {
8955                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8956                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8957                    }
8958                }
8959                let session_key = self.m4a_session_key(instance_id);
8960                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8961            }
8962            _ => environment_overlay,
8963        };
8964        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8965            instance_id,
8966            &record.provider,
8967            record.mode,
8968            record.environment_profile.as_ref(),
8969        ) {
8970            Ok(selection) => selection,
8971            Err(error) => {
8972                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8973                return Err(error);
8974            }
8975        };
8976        let mut instance_overlay = environment_overlay
8977            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8978            .transpose()?
8979            .flatten();
8980        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8981            self.remove_session(&stale_address).await?;
8982        }
8983        let address = SessionAddress {
8984            workspace_id: record.workspace_id.clone(),
8985            session: SessionKey {
8986                instance_id,
8987                generation: SessionGeneration(1),
8988            },
8989        };
8990        let transaction = self
8991            .state_transaction
8992            .lock()
8993            .unwrap_or_else(|poisoned| poisoned.into_inner());
8994        let current = self.record(record_id)?;
8995        if current.active_session.is_some()
8996            || matches!(
8997                current.state,
8998                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8999            )
9000        {
9001            return Err(failure(
9002                NodeFailureCode::SessionRecordBusy,
9003                "managed session became active while resume was being prepared",
9004            ));
9005        }
9006        self.bind_managed_session_with_materialization(
9007            &address,
9008            record_id.clone(),
9009            runtime_policy,
9010            record.environment_profile.clone(),
9011            record.bundle.clone(),
9012            record.context.clone(),
9013            materialization_id,
9014        );
9015        if let Some(selection) = environment_selection.take() {
9016            selection.retain();
9017        }
9018        if let Some(overlay) = instance_overlay.take() {
9019            overlay.retain();
9020        }
9021        drop(transaction);
9022
9023        let transport = match record.mode {
9024            SessionMode::Pty => TransportKind::Pty,
9025            SessionMode::Inline => TransportKind::Pipe,
9026            SessionMode::Acp => TransportKind::Acp,
9027        };
9028        let agent_id = record.provider.clone();
9029        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9030        if let Err(error) = self
9031            .dispatch_bounded(
9032                ControlCommand::Register {
9033                    instance_id,
9034                    agent_id,
9035                    transport,
9036                },
9037                dispatch_timeout,
9038            )
9039            .await
9040        {
9041            if let Some(binding) = self.remove_binding(&address) {
9042                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9043            }
9044            return Err(error);
9045        }
9046        let command = self.prepare_command(ControlCommand::Resume {
9047            instance_id,
9048            target: ResumeTarget::ProviderSession { identity },
9049            runtime_policy,
9050            request,
9051        });
9052        let started_after = self.current_sequence();
9053        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9054            self.rollback_spawn(&address, dispatch_timeout).await?;
9055            return Err(error);
9056        }
9057        if let Err(error) = self.dispatch_envelope(command) {
9058            self.clear_armed_resume(&address);
9059            self.rollback_spawn(&address, dispatch_timeout).await?;
9060            return Err(error);
9061        }
9062        match self
9063            .wait_for_record_resume(&address, started_after)
9064            .await
9065        {
9066            Ok(settled_address) => {
9067                let managed_lease_id = {
9068                    self.managed_worktrees.lock()
9069                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9070                        .lease_for_workspace(&record.workspace_id)
9071                };
9072                if let Some(lease_id) = managed_lease_id {
9073                    {
9074                        let mut bindings = self.session_bindings.lock()
9075                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9076                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9077                            .ok_or_else(|| failure(
9078                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9079                                "resumed managed session lost its exact binding",
9080                            ))?;
9081                        if binding.generation != settled_address.session.generation {
9082                            return Err(failure(
9083                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9084                                "resumed managed session generation diverged",
9085                            ));
9086                        }
9087                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9088                    }
9089                    let snapshot = self.managed_worktrees.lock()
9090                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9091                        .bind_session(
9092                            &lease_id,
9093                            ManagedWorktreeSessionHolder {
9094                                incarnation_id: self.incarnation_id,
9095                                instance_id: settled_address.session.instance_id,
9096                                generation: settled_address.session.generation,
9097                            },
9098                            Some(record_id.clone()),
9099                            unix_time_ms(),
9100                        ).map_err(|message| failure(
9101                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9102                            &message,
9103                        ))?;
9104                    if let Err(error) = self.persist_state() {
9105                        let _ = self.remove_session(&settled_address).await;
9106                        return Err(persistence_failure(error));
9107                    }
9108                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9109                }
9110                Ok((self.record(record_id)?, settled_address))
9111            }
9112            Err(error) => {
9113                if matches!(
9114                    error.code,
9115                    NodeFailureCode::SessionRecordNotResumable
9116                        | NodeFailureCode::BackendOperationFailed
9117                ) {
9118                    if let Some(bound) = self.bound_address_for_record(record_id) {
9119                        let _ = self.remove_session(&bound).await;
9120                    }
9121                }
9122                Err(error)
9123            }
9124        }
9125    }
9126
9127    async fn wait_for_record_resume(
9128        &self,
9129        initial_address: &SessionAddress,
9130        after_sequence: u64,
9131    ) -> Result<SessionAddress, NodeFailure> {
9132        let deadline = Instant::now()
9133            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9134        let mut scan_after = after_sequence;
9135        loop {
9136            let events = self
9137                .history
9138                .lock()
9139                .unwrap_or_else(|poisoned| poisoned.into_inner())
9140                .events
9141                .iter()
9142                .filter(|event| event.sequence > scan_after)
9143                .cloned()
9144                .collect::<Vec<_>>();
9145            for envelope in events {
9146                scan_after = scan_after.max(envelope.sequence);
9147                let NodeEvent::Control { address, event } = envelope.event else {
9148                    continue;
9149                };
9150                if address.session.instance_id != initial_address.session.instance_id {
9151                    continue;
9152                }
9153                match event.event {
9154                    ControlEventKind::Resumed { .. } => return Ok(address),
9155                    ControlEventKind::ResumeDenied { reason } => {
9156                        return Err(failure(
9157                            NodeFailureCode::SessionRecordNotResumable,
9158                            &reason,
9159                        ));
9160                    }
9161                    ControlEventKind::ResumeFailed { message }
9162                    | ControlEventKind::CommandRejected { message } => {
9163                        return Err(failure(
9164                            NodeFailureCode::BackendOperationFailed,
9165                            &message,
9166                        ));
9167                    }
9168                    _ => {}
9169                }
9170            }
9171            if Instant::now() >= deadline {
9172                return Err(failure(
9173                    NodeFailureCode::BackendBusy,
9174                    "managed resume did not settle before the bounded deadline",
9175                ));
9176            }
9177            sleep(Duration::from_millis(5)).await;
9178        }
9179    }
9180
9181    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9182        self.workspaces
9183            .read()
9184            .unwrap_or_else(|poisoned| poisoned.into_inner())
9185            .get(workspace_id)
9186            .cloned()
9187            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9188    }
9189
9190    fn workspace_roots(&self) -> Vec<PathBuf> {
9191        self.workspaces
9192            .read()
9193            .unwrap_or_else(|poisoned| poisoned.into_inner())
9194            .values()
9195            .map(PathBuf::from)
9196            .collect()
9197    }
9198
9199    async fn inspect_workspace(
9200        &self,
9201        workspace_id: WorkspaceId,
9202    ) -> Result<WorkspaceInspection, NodeFailure> {
9203        let _permit = self
9204            .inspection_slots
9205            .clone()
9206            .try_acquire_owned()
9207            .map_err(|_| {
9208                tracing::warn!(
9209                    workspace_id = %workspace_id,
9210                    "workspace inspection rejected: inspection capacity is busy",
9211                );
9212                failure(
9213                    NodeFailureCode::BackendBusy,
9214                    "workspace inspection capacity is busy",
9215                )
9216            })?;
9217        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9218            tracing::warn!(
9219                workspace_id = %workspace_id,
9220                code = ?error.code,
9221                "workspace inspection rejected: workspace is not registered",
9222            );
9223            error
9224        })?;
9225        let tree_root = canonical_root.clone();
9226        let started_at = Instant::now();
9227        let time_budget_ms = workspace_inspection_time_budget_ms();
9228        let entry_cap = workspace_inspection_entry_cap();
9229        let deadline = started_at + Duration::from_millis(time_budget_ms);
9230        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9231            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9232        })
9233        .await
9234        .map_err(|error| {
9235            tracing::warn!(
9236                workspace_id = %workspace_id,
9237                error = %error,
9238                "workspace inspection rejected: walk task failed",
9239            );
9240            failure(
9241                NodeFailureCode::BackendOperationFailed,
9242                &format!("workspace inspection task failed: {error}"),
9243            )
9244        })?;
9245        let (mut git, git_time_budget_exceeded) =
9246            inspect_git_workspace(&canonical_root, deadline).await;
9247        if !git.worktrees.is_empty() {
9248            let registered = self
9249                .workspaces
9250                .read()
9251                .unwrap_or_else(|poisoned| poisoned.into_inner())
9252                .iter()
9253                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9254                .collect::<Vec<_>>();
9255            for worktree in &mut git.worktrees {
9256                worktree.workspace_id = registered
9257                    .iter()
9258                    .find(|(_, root)| {
9259                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9260                    })
9261                    .map(|(workspace_id, _)| workspace_id.clone());
9262            }
9263        }
9264        if git.is_repository {
9265            git.managed_worktree = self
9266                .managed_worktrees
9267                .lock()
9268                .unwrap_or_else(|poisoned| poisoned.into_inner())
9269                .git_scope(&workspace_id, git.branch.as_deref());
9270        }
9271        let truncation = workspace_inspection_truncation(
9272            &walk_budget,
9273            git_time_budget_exceeded,
9274            started_at.elapsed(),
9275        );
9276        if let Some(truncation) = &truncation {
9277            tracing::info!(
9278                workspace_id = %workspace_id,
9279                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9280                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9281                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9282                entries_visited = truncation.entries_visited,
9283                elapsed_ms = truncation.elapsed_ms,
9284                "workspace inspection truncated by its inner budget",
9285            );
9286        }
9287        Ok(WorkspaceInspection {
9288            workspace_id,
9289            entries,
9290            tree_truncated,
9291            git,
9292            truncation,
9293        })
9294    }
9295
9296    async fn browse_host_directories(
9297        &self,
9298        directory: Option<OpaqueHostPath>,
9299        after: Option<OpaqueHostPath>,
9300    ) -> Result<HostDirectoryListing, NodeFailure> {
9301        let permit = self
9302            .inspection_slots
9303            .clone()
9304            .try_acquire_owned()
9305            .map_err(|_| {
9306                failure(
9307                    NodeFailureCode::BackendBusy,
9308                    "host directory browse capacity is busy",
9309                )
9310            })?;
9311        let task = tokio::task::spawn_blocking(move || {
9312            let _permit = permit;
9313            browse_host_directories(directory, after)
9314        });
9315        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9316            .await
9317            .map_err(|_| {
9318                failure(
9319                    NodeFailureCode::HostDirectoryReadTimedOut,
9320                    "host directory browse exceeded its bounded deadline",
9321                )
9322            })?
9323            .map_err(|_| {
9324                failure(
9325                    NodeFailureCode::HostDirectoryReadFailed,
9326                    "host directory browse task failed",
9327                )
9328            })?
9329            .map_err(host_directory_failure)
9330    }
9331
9332    async fn read_workspace_file(
9333        &self,
9334        workspace_id: WorkspaceId,
9335        path: RepositoryPath,
9336    ) -> Result<WorkspaceFileRead, NodeFailure> {
9337        let canonical_root = self.workspace_root(&workspace_id)?;
9338        let permit = self
9339            .inspection_slots
9340            .clone()
9341            .try_acquire_owned()
9342            .map_err(|_| {
9343                failure(
9344                    NodeFailureCode::BackendBusy,
9345                    "workspace file read capacity is busy",
9346                )
9347            })?;
9348        let path_to_read = path.clone();
9349        let task = tokio::task::spawn_blocking(move || {
9350            let _permit = permit;
9351            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9352        });
9353        let content = timeout(
9354            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9355            task,
9356        )
9357        .await
9358        .map_err(|_| {
9359            failure(
9360                NodeFailureCode::RepositoryFileReadTimedOut,
9361                "repository file read exceeded its bounded deadline",
9362            )
9363        })?
9364        .map_err(|_| {
9365            failure(
9366                NodeFailureCode::RepositoryFileReadFailed,
9367                "repository file read task failed",
9368            )
9369        })?
9370        .map_err(workspace_file_failure)?;
9371        let content = match content {
9372            WorkspaceFileBytes::Utf8(text) => {
9373                let revision = Some(workspace_file_revision(text.as_bytes()));
9374                return Ok(WorkspaceFileRead {
9375                    workspace_id,
9376                    path,
9377                    content: WorkspaceFileContent::Utf8 {
9378                        byte_len: u32::try_from(text.len())
9379                            .expect("bounded workspace text length must fit u32"),
9380                        text,
9381                    },
9382                    revision,
9383                });
9384            }
9385            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9386                WorkspaceFileContent::NonUtf8 {
9387                    byte_len: u32::try_from(byte_length)
9388                        .expect("bounded workspace file length must fit u32"),
9389                }
9390            }
9391            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9392                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9393            },
9394        };
9395        Ok(WorkspaceFileRead {
9396            workspace_id,
9397            path,
9398            content,
9399            revision: None,
9400        })
9401    }
9402
9403    async fn write_workspace_file(
9404        &self,
9405        workspace_id: WorkspaceId,
9406        path: RepositoryPath,
9407        expected_revision: WorkspaceFileRevision,
9408        text: String,
9409    ) -> Result<WorkspaceFileRead, NodeFailure> {
9410        let canonical_root = self.workspace_root(&workspace_id)?;
9411        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9412            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9413        })?;
9414        let path_to_write = path.clone();
9415        let expected = expected_revision.as_str().to_owned();
9416        let text_to_write = text.clone();
9417        let task = tokio::task::spawn_blocking(move || {
9418            let _permit = permit;
9419            write_workspace_file_to_disk(
9420                Path::new(&canonical_root),
9421                &path_to_write,
9422                &expected,
9423                &text_to_write,
9424            )
9425        });
9426        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9427            .await
9428            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9429            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9430            .map_err(workspace_file_write_failure)?;
9431        Ok(WorkspaceFileRead {
9432            workspace_id,
9433            path,
9434            content: WorkspaceFileContent::Utf8 {
9435                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9436                text,
9437            },
9438            revision: Some(WorkspaceFileRevision::new(revision)
9439                .expect("workspace writer returns a valid SHA-256 revision")),
9440        })
9441    }
9442
9443    async fn create_workspace_file(
9444        &self,
9445        workspace_id: WorkspaceId,
9446        path: RepositoryPath,
9447    ) -> Result<WorkspaceFileRead, NodeFailure> {
9448        let canonical_root = self.workspace_root(&workspace_id)?;
9449        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9450            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9451        })?;
9452        let path_to_create = path.clone();
9453        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9454        let task_commit_state = Arc::clone(&commit_state);
9455        let task = tokio::task::spawn_blocking(move || {
9456            let _permit = permit;
9457            create_workspace_file_on_disk(
9458                Path::new(&canonical_root),
9459                &path_to_create,
9460                &task_commit_state,
9461            )
9462        });
9463        let revision = settle_workspace_entry_create(
9464            task,
9465            commit_state,
9466            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9467            "repository file create exceeded its bounded deadline",
9468            "repository file create task failed",
9469        )
9470        .await?;
9471        Ok(WorkspaceFileRead {
9472            workspace_id,
9473            path,
9474            content: WorkspaceFileContent::Utf8 {
9475                text: String::new(),
9476                byte_len: 0,
9477            },
9478            revision: Some(WorkspaceFileRevision::new(revision)
9479                .expect("workspace creator returns a valid SHA-256 revision")),
9480        })
9481    }
9482
9483    async fn create_workspace_directory(
9484        &self,
9485        workspace_id: WorkspaceId,
9486        path: RepositoryPath,
9487    ) -> Result<WorkspaceEntry, NodeFailure> {
9488        let canonical_root = self.workspace_root(&workspace_id)?;
9489        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9490            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9491        })?;
9492        let path_to_create = path.clone();
9493        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9494        let task_commit_state = Arc::clone(&commit_state);
9495        let task = tokio::task::spawn_blocking(move || {
9496            let _permit = permit;
9497            create_workspace_directory_on_disk(
9498                Path::new(&canonical_root),
9499                &path_to_create,
9500                &task_commit_state,
9501            )
9502        });
9503        settle_workspace_entry_create(
9504            task,
9505            commit_state,
9506            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9507            "repository directory create exceeded its bounded deadline",
9508            "repository directory create task failed",
9509        )
9510        .await?;
9511        Ok(WorkspaceEntry {
9512            relative_path: path,
9513            kind: WorkspaceEntryKind::Directory,
9514        })
9515    }
9516
9517    async fn read_git_history(
9518        &self,
9519        workspace_id: WorkspaceId,
9520        path: Option<RepositoryPath>,
9521        before: Option<GitObjectId>,
9522        limit: u16,
9523    ) -> Result<GitHistoryPage, NodeFailure> {
9524        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9525            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9526        })?;
9527        let root = self.workspace_root(&workspace_id)?;
9528        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9529    }
9530
9531    async fn read_git_diff(
9532        &self,
9533        workspace_id: WorkspaceId,
9534        request: GitDiffRequest,
9535    ) -> Result<GitDiff, NodeFailure> {
9536        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9537            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9538        })?;
9539        let root = self.workspace_root(&workspace_id)?;
9540        read_git_diff_bounded(&root, request).await
9541    }
9542
9543    fn native_session_route_root(
9544        &self,
9545        route: &NativeSessionCatalogRoute,
9546    ) -> Result<Option<String>, NodeFailure> {
9547        route
9548            .validate()
9549            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9550        match route.scope {
9551            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9552                .workspace_id
9553                .as_ref()
9554                .map(|workspace_id| self.workspace_root(workspace_id))
9555                .transpose(),
9556            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9557        }
9558    }
9559
9560    fn project_native_session_entries(
9561        &self,
9562        route: &NativeSessionCatalogRoute,
9563        entries: Vec<ScopedNativeSessionCatalogEntry>,
9564    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9565        let current_root = route
9566            .workspace_id
9567            .as_ref()
9568            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9569        let records = self
9570            .session_records
9571            .lock()
9572            .unwrap_or_else(|poisoned| poisoned.into_inner());
9573        let mut seen_records = std::collections::HashSet::new();
9574        entries
9575            .into_iter()
9576            .map(|entry| {
9577                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9578                    let matches = records
9579                        .records
9580                        .values()
9581                        .filter(|record| {
9582                            record.provider == route.provider
9583                                && record.workspace_id == *workspace_id
9584                                && current_root.as_deref().is_some_and(|root| {
9585                                    platform::roots_equal(
9586                                        root,
9587                                        windows_path_text(&record.canonical_root),
9588                                    )
9589                                })
9590                                && record.provider_session.as_ref().is_some_and(|identity| {
9591                                    session_registry::same_provider_session(
9592                                        &record.provider,
9593                                        identity,
9594                                        &route.provider,
9595                                        &entry.provider_session,
9596                                    )
9597                                })
9598                        })
9599                        .map(|record| record.record_id.clone())
9600                        .collect::<Vec<_>>();
9601                    (matches.len() == 1
9602                        && seen_records.insert(matches[0].clone()))
9603                        .then(|| matches[0].clone())
9604                });
9605                let metadata = entry.metadata;
9606                crate::protocol::NativeSessionCatalogEntry {
9607                    selection_id: metadata.selection_id,
9608                    title: metadata.title,
9609                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9610                    model: metadata.model,
9611                    message_count: metadata.message_count,
9612                    completed_turn_count: metadata.completed_turn_count,
9613                    external_group: entry.external_group,
9614                    record_id,
9615                }
9616            })
9617            .collect()
9618    }
9619
9620    async fn catalog_native_sessions(
9621        &self,
9622        route: NativeSessionCatalogRoute,
9623        limit: u16,
9624    ) -> Result<(
9625        Vec<crate::protocol::NativeSessionCatalogEntry>,
9626        crate::protocol::NativeSessionCatalogSummary,
9627    ), NodeFailure> {
9628        let canonical_root = self.native_session_route_root(&route)?;
9629        let registered_workspace_roots = self.workspace_roots();
9630        let provider = route.provider.clone();
9631        let scope = route.scope;
9632        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9633            failure(
9634                NodeFailureCode::InvalidRequest,
9635                "native session catalog is not configured",
9636            )
9637        })?;
9638        let _permit = Arc::clone(&self.inspection_slots)
9639            .try_acquire_owned()
9640            .map_err(|_| {
9641                failure(
9642                    NodeFailureCode::BackendBusy,
9643                    "native session catalog capacity is busy",
9644                )
9645            })?;
9646        let page = run_native_session_catalog_operation(
9647            catalog,
9648            "native session catalog",
9649            move |catalog| {
9650                catalog.catalog_initial_for_scope(
9651                    &provider,
9652                    scope,
9653                    canonical_root.as_deref().map(Path::new),
9654                    &registered_workspace_roots,
9655                    limit,
9656                )
9657            },
9658        )
9659        .await?;
9660        let summary = crate::protocol::NativeSessionCatalogSummary {
9661            catalog_revision: page.revision,
9662            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9663            recent_total_count: page.recent_total_count,
9664            older_total_count: page.older_total_count,
9665            recent_next_after_selection_id: page.next_after_selection_id,
9666            recent_has_more: page.remaining_count > 0,
9667        };
9668        Ok((self.project_native_session_entries(&route, page.entries), summary))
9669    }
9670
9671    async fn page_native_sessions(
9672        &self,
9673        route: NativeSessionCatalogRoute,
9674        window: crate::protocol::NativeSessionCatalogWindow,
9675        catalog_revision: u64,
9676        recent_cutoff_unix_ms: u64,
9677        after_selection_id: Option<String>,
9678        limit: u16,
9679    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9680        let canonical_root = self.native_session_route_root(&route)?;
9681        let registered_workspace_roots = self.workspace_roots();
9682        let provider = route.provider.clone();
9683        let scope = route.scope;
9684        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9685            failure(
9686                NodeFailureCode::InvalidRequest,
9687                "native session catalog is not configured",
9688            )
9689        })?;
9690        let _permit = Arc::clone(&self.inspection_slots)
9691            .try_acquire_owned()
9692            .map_err(|_| {
9693                failure(
9694                    NodeFailureCode::BackendBusy,
9695                    "native session catalog capacity is busy",
9696                )
9697            })?;
9698        let page = run_native_session_catalog_operation(
9699            catalog,
9700            "native session catalog page",
9701            move |catalog| {
9702                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9703                    &provider,
9704                    scope,
9705                    canonical_root.as_deref().map(Path::new),
9706                    &registered_workspace_roots,
9707                    window,
9708                    catalog_revision,
9709                    recent_cutoff_unix_ms,
9710                    after_selection_id.as_deref(),
9711                    limit,
9712                ))
9713            },
9714        )
9715        .await?;
9716        let page = page.map_err(|error| match error {
9717            NativeSessionCatalogError::StaleCatalog => failure(
9718                NodeFailureCode::StaleNativeSessionCatalog,
9719                "native session catalog revision or cursor is stale",
9720            ),
9721            _ => failure(
9722                NodeFailureCode::BackendOperationFailed,
9723                "native session catalog page failed",
9724            ),
9725        })?;
9726        let entries = self.project_native_session_entries(&route, page.entries);
9727        Ok(crate::protocol::NativeSessionCatalogPage {
9728            window: page.window,
9729            revision: page.revision,
9730            entries,
9731            next_after_selection_id: page.next_after_selection_id,
9732            remaining_count: page.remaining_count,
9733            has_more: page.remaining_count > 0,
9734        })
9735    }
9736
9737    async fn preview_native_session(
9738        &self,
9739        selection: NativeSessionSelection,
9740        message_limit: u16,
9741    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9742        selection
9743            .validate()
9744            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9745        let canonical_root = self.native_session_route_root(&selection.route)?;
9746        let registered_workspace_roots = self.workspace_roots();
9747        let provider = selection.route.provider.clone();
9748        let scope = selection.route.scope;
9749        let catalog_revision = selection.catalog_revision;
9750        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9751        let selection_id = selection.selection_id.clone();
9752        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9753            failure(
9754                NodeFailureCode::InvalidRequest,
9755                "native session preview is not configured",
9756            )
9757        })?;
9758        let _permit = Arc::clone(&self.inspection_slots)
9759            .try_acquire_owned()
9760            .map_err(|_| {
9761                failure(
9762                    NodeFailureCode::BackendBusy,
9763                    "native session preview capacity is busy",
9764                )
9765            })?;
9766        let preview = run_native_session_catalog_operation(
9767            catalog,
9768            "native session preview",
9769            move |catalog| {
9770                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9771                    &provider,
9772                    scope,
9773                    canonical_root.as_deref().map(Path::new),
9774                    &registered_workspace_roots,
9775                    catalog_revision,
9776                    recent_cutoff_unix_ms,
9777                    &selection_id,
9778                    message_limit,
9779                ))
9780            },
9781        )
9782        .await?;
9783        preview
9784            .map(Into::into)
9785            .map_err(native_session_preview_failure)
9786    }
9787
9788    async fn index_native_session(
9789        &self,
9790        selection: NativeSessionSelection,
9791        display_name: String,
9792    ) -> Result<ManagedSessionRecord, NodeFailure> {
9793        selection
9794            .validate()
9795            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9796        if selection.route.scope
9797            != gate4agent_types::NativeSessionCatalogScope::Workspace
9798        {
9799            return Err(failure(
9800                NodeFailureCode::WorkspaceRegistrationRequired,
9801                "register the external project as a workspace before indexing or resuming it",
9802            ));
9803        }
9804        let workspace_id = selection
9805            .route
9806            .workspace_id
9807            .clone()
9808            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9809        let canonical_root = self
9810            .native_session_route_root(&selection.route)?
9811            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9812        let registered_workspace_roots = self.workspace_roots();
9813        let provider = selection.route.provider.clone();
9814        let catalog_revision = selection.catalog_revision;
9815        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9816        let selection_id = selection.selection_id.clone();
9817        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9818            failure(
9819                NodeFailureCode::InvalidRequest,
9820                "native session index is not configured",
9821            )
9822        })?;
9823        let _permit = Arc::clone(&self.inspection_slots)
9824            .try_acquire_owned()
9825            .map_err(|_| {
9826                failure(
9827                    NodeFailureCode::BackendBusy,
9828                    "native session index capacity is busy",
9829                )
9830            })?;
9831        let resolved = run_native_session_catalog_operation(
9832            catalog,
9833            "native session index resolution",
9834            move |catalog| {
9835                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9836                    &provider,
9837                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9838                    Some(Path::new(&canonical_root)),
9839                    &registered_workspace_roots,
9840                    catalog_revision,
9841                    recent_cutoff_unix_ms,
9842                    &selection_id,
9843                ))
9844            },
9845        )
9846        .await?
9847        .map_err(native_session_preview_failure)?;
9848        self.index_provider_session_with_policy(
9849            workspace_id,
9850            selection.route.provider,
9851            resolved.identity,
9852            display_name,
9853            true,
9854        )
9855    }
9856
9857    async fn preview_native_session_by_session_id(
9858        &self,
9859        workspace_id: WorkspaceId,
9860        provider: AgentId,
9861        session_id: String,
9862        message_limit: u16,
9863    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9864        let canonical_root = self.workspace_root(&workspace_id)?;
9865        let registered_workspace_roots = self.workspace_roots();
9866        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9867            failure(
9868                NodeFailureCode::InvalidRequest,
9869                "native session preview is not configured",
9870            )
9871        })?;
9872        let _permit = Arc::clone(&self.inspection_slots)
9873            .try_acquire_owned()
9874            .map_err(|_| {
9875                failure(
9876                    NodeFailureCode::BackendBusy,
9877                    "native session preview capacity is busy",
9878                )
9879            })?;
9880        run_native_session_catalog_operation(
9881            catalog,
9882            "native session preview",
9883            move |catalog| {
9884                catalog.preview_session_id_for_workspace(
9885                    &provider,
9886                    Path::new(&canonical_root),
9887                    &registered_workspace_roots,
9888                    &session_id,
9889                    message_limit,
9890                )
9891            },
9892        )
9893        .await
9894    }
9895
9896    async fn register_workspace(
9897        &self,
9898        workspace_id: WorkspaceId,
9899        root: String,
9900    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9901        validate_workspace_request_root(&root)?;
9902        let canonicalize_workspace_id = workspace_id.clone();
9903        let workspace = tokio::task::spawn_blocking(move || {
9904            WorkspaceConfig::new(canonicalize_workspace_id, root)
9905        })
9906        .await
9907        .map_err(|error| {
9908            failure(
9909                NodeFailureCode::BackendOperationFailed,
9910                &format!("workspace canonicalization task failed: {error}"),
9911            )
9912        })?
9913        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9914        let transaction = self
9915            .state_transaction
9916            .lock()
9917            .unwrap_or_else(|poisoned| poisoned.into_inner());
9918        let mut workspaces = self
9919            .workspaces
9920            .write()
9921            .unwrap_or_else(|poisoned| poisoned.into_inner());
9922        if workspaces.contains_key(&workspace_id) {
9923            return Err(failure(
9924                NodeFailureCode::DuplicateWorkspaceId,
9925                "workspace ID is already registered",
9926            ));
9927        }
9928        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9929            platform::roots_equal(existing_root, workspace.canonical_root())
9930        }) {
9931            return Err(failure(
9932                NodeFailureCode::DuplicateWorkspaceRoot,
9933                &format!("workspace root is already registered as '{existing_id}'"),
9934            ));
9935        }
9936        {
9937            let records = self
9938                .session_records
9939                .lock()
9940                .unwrap_or_else(|poisoned| poisoned.into_inner());
9941            if let Some(record) = records.records.values().find(|record| {
9942                record.workspace_id == workspace_id
9943                    && !record
9944                        .canonical_root
9945                        .as_utf8()
9946                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9947            }) {
9948                return Err(failure(
9949                    NodeFailureCode::SessionRecordConflict,
9950                    &format!(
9951                        "workspace ID is retained by session record '{}' at another root",
9952                        record.record_id,
9953                    ),
9954                ));
9955            }
9956        }
9957        let snapshot = WorkspaceSnapshot {
9958            workspace_id: workspace_id.clone(),
9959            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9960            sessions: Vec::new(),
9961            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9962            managed_worktree_profiles: Some(WorktreeProfileInventory {
9963                profiles: Vec::new(),
9964            }),
9965        };
9966        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9967        drop(workspaces);
9968        let previous_records = {
9969            let mut records = self
9970                .session_records
9971                .lock()
9972                .unwrap_or_else(|poisoned| poisoned.into_inner());
9973            let previous = records.records.clone();
9974            for record in records.records.values_mut().filter(|record| {
9975                record.workspace_id == workspace_id
9976                    && record
9977                        .canonical_root
9978                        .as_utf8()
9979                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9980            }) {
9981                if record.active_session.is_none() {
9982                    record.state = detached_record_state(record);
9983                }
9984                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9985                    record.last_error = None;
9986                }
9987                record.updated_at_unix_ms = unix_time_ms();
9988            }
9989            previous
9990        };
9991        if let Err(error) = self.persist_state_locked() {
9992            self.workspaces
9993                .write()
9994                .unwrap_or_else(|poisoned| poisoned.into_inner())
9995                .remove(&workspace_id);
9996            self.session_records
9997                .lock()
9998                .unwrap_or_else(|poisoned| poisoned.into_inner())
9999                .records = previous_records;
10000            return Err(persistence_failure(error));
10001        }
10002        let restored = self
10003            .session_records
10004            .lock()
10005            .unwrap_or_else(|poisoned| poisoned.into_inner())
10006            .records
10007            .values()
10008            .filter(|record| record.workspace_id == workspace_id)
10009            .cloned()
10010            .collect::<Vec<_>>();
10011        drop(transaction);
10012        self.publish(NodeEvent::WorkspaceAdded {
10013            workspace: snapshot.clone(),
10014        });
10015        for record in restored {
10016            self.publish_record(record);
10017        }
10018        Ok(snapshot)
10019    }
10020
10021    async fn create_standalone_workspace(
10022        &self,
10023        workspace_id: WorkspaceId,
10024        root: String,
10025        initial_branch: Option<String>,
10026    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10027        validate_workspace_request_root(&root)?;
10028        validate_git_revision("initial branch", initial_branch.as_deref())?;
10029        let preflight_workspace_id = workspace_id.clone();
10030        let preflight_root = root.clone();
10031        let canonical_candidate = tokio::task::spawn_blocking(move || {
10032            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10033        })
10034        .await
10035        .map_err(|_| failure(
10036            NodeFailureCode::BackendOperationFailed,
10037            "standalone workspace root preflight task failed",
10038        ))?
10039        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10040        {
10041            let workspaces = self
10042                .workspaces
10043                .read()
10044                .unwrap_or_else(|poisoned| poisoned.into_inner());
10045            if workspaces.contains_key(&workspace_id) {
10046                return Err(failure(
10047                    NodeFailureCode::DuplicateWorkspaceId,
10048                    "workspace ID is already registered",
10049                ));
10050            }
10051            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10052                platform::roots_equal(existing_root, &canonical_candidate)
10053            }) {
10054                return Err(failure(
10055                    NodeFailureCode::DuplicateWorkspaceRoot,
10056                    &format!("workspace root is already registered as '{existing_id}'"),
10057                ));
10058            }
10059        }
10060        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10061            .await
10062            .map_err(standalone_workspace_failure)?;
10063        prepared
10064            .verify_for_registration()
10065            .await
10066            .map_err(standalone_workspace_failure)?;
10067        match self
10068            .register_workspace(workspace_id, prepared.root().to_owned())
10069            .await
10070        {
10071            Ok(workspace) => Ok(workspace),
10072            Err(registration_error) => {
10073                if prepared.compensate_registration_failure().await {
10074                    Err(registration_error)
10075                } else {
10076                    Err(failure(
10077                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10078                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10079                    ))
10080                }
10081            }
10082        }
10083    }
10084
10085    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10086        let bindings = self
10087            .session_bindings
10088            .lock()
10089            .unwrap_or_else(|poisoned| poisoned.into_inner());
10090        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10091            return Err(failure(
10092                NodeFailureCode::WorkspaceBusy,
10093                "workspace retains a bound session; remove every session before unregistering it",
10094            ));
10095        }
10096        drop(bindings);
10097        let transaction = self
10098            .state_transaction
10099            .lock()
10100            .unwrap_or_else(|poisoned| poisoned.into_inner());
10101        let mut workspaces = self
10102            .workspaces
10103            .write()
10104            .unwrap_or_else(|poisoned| poisoned.into_inner());
10105        if !workspaces.contains_key(workspace_id) {
10106            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10107        }
10108        if workspaces.len() == 1 {
10109            return Err(failure(
10110                NodeFailureCode::LastWorkspace,
10111                "node must retain at least one workspace",
10112            ));
10113        }
10114        let removed_root = workspaces
10115            .remove(workspace_id)
10116            .expect("workspace presence was validated");
10117        drop(workspaces);
10118        let previous_records = {
10119            let mut records = self
10120                .session_records
10121                .lock()
10122                .unwrap_or_else(|poisoned| poisoned.into_inner());
10123            let previous = records.records.clone();
10124            for record in records
10125                .records
10126                .values_mut()
10127                .filter(|record| &record.workspace_id == workspace_id)
10128            {
10129                record.active_session = None;
10130                record.state = ManagedSessionState::Unavailable;
10131                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10132                record.updated_at_unix_ms = unix_time_ms();
10133            }
10134            previous
10135        };
10136        if let Err(error) = self.persist_state_locked() {
10137            self.workspaces
10138                .write()
10139                .unwrap_or_else(|poisoned| poisoned.into_inner())
10140                .insert(workspace_id.clone(), removed_root);
10141            self.session_records
10142                .lock()
10143                .unwrap_or_else(|poisoned| poisoned.into_inner())
10144                .records = previous_records;
10145            return Err(persistence_failure(error));
10146        }
10147        let unavailable = self
10148            .session_records
10149            .lock()
10150            .unwrap_or_else(|poisoned| poisoned.into_inner())
10151            .records
10152            .values()
10153            .filter(|record| &record.workspace_id == workspace_id)
10154            .cloned()
10155            .collect::<Vec<_>>();
10156        drop(transaction);
10157        self.publish(NodeEvent::WorkspaceRemoved {
10158            workspace_id: workspace_id.clone(),
10159        });
10160        for record in unavailable {
10161            self.publish_record(record);
10162        }
10163        Ok(())
10164    }
10165
10166    async fn create_worktree(
10167        &self,
10168        source_workspace_id: WorkspaceId,
10169        workspace_id: WorkspaceId,
10170        target_root: String,
10171        branch: String,
10172        base: Option<String>,
10173    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10174        self.require_worktree_service(&source_workspace_id)?;
10175        validate_workspace_request_root(&target_root)?;
10176        validate_git_revision("worktree base", base.as_deref())?;
10177        let source_root = self.workspace_root(&source_workspace_id)?;
10178        {
10179            let workspaces = self
10180                .workspaces
10181                .read()
10182                .unwrap_or_else(|poisoned| poisoned.into_inner());
10183            if workspaces.contains_key(&workspace_id) {
10184                return Err(failure(
10185                    NodeFailureCode::DuplicateWorkspaceId,
10186                    "workspace ID is already registered",
10187                ));
10188            }
10189            if let Some((existing_id, _)) = workspaces
10190                .iter()
10191                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10192            {
10193                return Err(failure(
10194                    NodeFailureCode::DuplicateWorkspaceRoot,
10195                    &format!("workspace root is already registered as '{existing_id}'"),
10196                ));
10197            }
10198        }
10199        let mut worktree = create_git_worktree(
10200            &source_root,
10201            &target_root,
10202            &branch,
10203            base.as_deref(),
10204        )
10205        .await
10206        .map_err(git_worktree_failure)?;
10207        let workspace = match self
10208            .register_workspace(workspace_id, worktree.path.clone())
10209            .await
10210        {
10211            Ok(workspace) => workspace,
10212            Err(registration_error) => {
10213                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10214                    return Err(failure(
10215                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10216                        "worktree registration failed and compensation requires recovery",
10217                    ));
10218                }
10219                return Err(failure(
10220                    registration_error.code,
10221                    &registration_error.message,
10222                ));
10223            }
10224        };
10225        worktree.workspace_id = Some(workspace.workspace_id.clone());
10226        Ok((protocol_worktree(worktree), workspace))
10227    }
10228
10229    async fn remove_worktree(
10230        &self,
10231        source_workspace_id: WorkspaceId,
10232        target_root: String,
10233    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10234        self.require_worktree_service(&source_workspace_id)?;
10235        validate_workspace_request_root(&target_root)?;
10236        let source_root = self.workspace_root(&source_workspace_id)?;
10237        let target_root = normalize_worktree_removal_target(&target_root)
10238            .map_err(git_worktree_failure)?;
10239        let target = list_git_worktrees(&source_root)
10240            .await
10241            .map_err(git_worktree_failure)?
10242            .into_iter()
10243            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10244            .ok_or_else(|| {
10245                failure(
10246                    NodeFailureCode::WorktreeProtected,
10247                    "refusing to remove a path that is not in Git's worktree listing",
10248                )
10249            })?;
10250        let registered_workspace_id = {
10251            let workspaces = self
10252                .workspaces
10253                .read()
10254                .unwrap_or_else(|poisoned| poisoned.into_inner());
10255            let matched = workspaces
10256                .iter()
10257                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10258                .map(|(workspace_id, _)| workspace_id.clone());
10259            if matched.is_some() && workspaces.len() == 1 {
10260                return Err(failure(
10261                    NodeFailureCode::LastWorkspace,
10262                    "node must retain at least one workspace",
10263                ));
10264            }
10265            matched
10266        };
10267        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10268            let bindings = self
10269                .session_bindings
10270                .lock()
10271                .unwrap_or_else(|poisoned| poisoned.into_inner());
10272            if bindings
10273                .values()
10274                .any(|binding| &binding.workspace_id == workspace_id)
10275            {
10276                return Err(failure(
10277                    NodeFailureCode::WorkspaceBusy,
10278                    "worktree workspace retains a bound session; remove every session first",
10279                ));
10280            }
10281        }
10282        remove_git_worktree(&source_root, &target.path)
10283            .await
10284            .map_err(git_worktree_failure)?;
10285        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10286            self.unregister_workspace(workspace_id)
10287                .map_err(|error| failure(error.code, &error.message))?;
10288        }
10289        Ok(registered_workspace_id)
10290    }
10291
10292    async fn cleanup_managed_worktree(
10293        &self,
10294        lease_id: &ManagedWorktreeLeaseId,
10295        explicit: bool,
10296    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10297        let lease = self.managed_worktrees.lock()
10298            .unwrap_or_else(|poisoned| poisoned.into_inner())
10299            .get(lease_id)
10300            .cloned()
10301            .ok_or_else(|| failure(
10302                NodeFailureCode::UnknownManagedWorktreeLease,
10303                "managed worktree lease does not exist",
10304            ))?;
10305        if lease.state == ManagedWorktreeLeaseState::Removed {
10306            return Ok(lease.snapshot());
10307        }
10308        if lease.has_holders() {
10309            return Err(failure(
10310                NodeFailureCode::ManagedWorktreeBusy,
10311                "managed worktree lease still has a session or durable-record holder",
10312            ));
10313        }
10314        if self
10315            .materializations
10316            .lock()
10317            .unwrap_or_else(|poisoned| poisoned.into_inner())
10318            .values()
10319            .any(|record| record.managed_lease_id() == Some(lease_id))
10320        {
10321            return Err(failure(
10322                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10323                "managed worktree cleanup is blocked by session environment ownership",
10324            ));
10325        }
10326        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10327            let snapshot = {
10328                let mut registry = self.managed_worktrees.lock()
10329                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10330                let current = registry.get_mut(lease_id)
10331                    .expect("managed worktree lease remains present");
10332                current.state = ManagedWorktreeLeaseState::Retained;
10333                current.cleanup_failure = None;
10334                current.updated_at_unix_ms = unix_time_ms();
10335                current.snapshot()
10336            };
10337            if self.persist_state().is_err() {
10338                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10339            }
10340            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10341            return Ok(snapshot);
10342        }
10343        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10344        if profile.revision() != &lease.profile_revision {
10345            self.mark_managed_recovery_required(
10346                lease_id,
10347                ManagedWorktreeCleanupFailure::OwnershipConflict,
10348            );
10349            return Err(failure(
10350                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10351                "managed worktree profile revision changed",
10352            ));
10353        }
10354        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10355            self.mark_managed_recovery_required(
10356                lease_id,
10357                ManagedWorktreeCleanupFailure::OwnershipConflict,
10358            );
10359            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10360        })?;
10361        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10362        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10363            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10364            managed_git_worktree_failure(error)
10365        })?;
10366        let target = listed.iter()
10367            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10368        if let Some(target) = target {
10369            if target.is_main || target.is_bare || !exact_owned_worktree(
10370                &lease,
10371                &target.path,
10372                target.branch.as_deref(),
10373                &target.head,
10374            ) {
10375                self.mark_managed_recovery_required(
10376                    lease_id,
10377                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10378                );
10379                return Err(failure(
10380                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10381                    "Git worktree identity no longer matches the managed lease",
10382                ));
10383            }
10384            if target.prunable {
10385                self.mark_managed_cleanup_blocked(
10386                    lease_id,
10387                    ManagedWorktreeCleanupFailure::Prunable,
10388                );
10389                return Err(failure(
10390                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10391                    "managed worktree is prunable and requires recovery",
10392                ));
10393            }
10394            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10395                let failure_kind = match error.kind {
10396                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10397                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10398                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10399                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10400                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10401                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10402                };
10403                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10404                    self.mark_managed_recovery_required(lease_id, failure_kind);
10405                } else {
10406                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10407                }
10408                return Err(managed_git_worktree_failure(error));
10409            }
10410        } else {
10411            match std::fs::symlink_metadata(&lease.target_root) {
10412                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10413                Ok(_) => {
10414                    self.mark_managed_recovery_required(
10415                        lease_id,
10416                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10417                    );
10418                    return Err(failure(
10419                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10420                        "managed target exists but is not owned by Git",
10421                    ));
10422                }
10423                Err(_) => {
10424                    self.mark_managed_cleanup_blocked(
10425                        lease_id,
10426                        ManagedWorktreeCleanupFailure::Backend,
10427                    );
10428                    return Err(failure(
10429                        NodeFailureCode::BackendOperationFailed,
10430                        "managed target identity could not be inspected",
10431                    ));
10432                }
10433            }
10434        }
10435        let registered = self.workspaces.read()
10436            .unwrap_or_else(|poisoned| poisoned.into_inner())
10437            .contains_key(&lease.workspace_id);
10438        if registered {
10439            self.unregister_workspace(&lease.workspace_id)?;
10440        }
10441        let removed = {
10442            self.managed_worktrees.lock()
10443                .unwrap_or_else(|poisoned| poisoned.into_inner())
10444                .tombstone(lease_id, unix_time_ms())
10445                .expect("managed worktree lease remains present during cleanup")
10446        };
10447        self.persist_state().map_err(persistence_failure)?;
10448        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10449        Ok(removed.snapshot())
10450    }
10451
10452    fn mark_managed_cleanup_blocked(
10453        &self,
10454        lease_id: &ManagedWorktreeLeaseId,
10455        failure_kind: ManagedWorktreeCleanupFailure,
10456    ) {
10457        let snapshot = {
10458            let mut registry = self.managed_worktrees.lock()
10459                .unwrap_or_else(|poisoned| poisoned.into_inner());
10460            let Some(lease) = registry.get_mut(lease_id) else { return };
10461            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10462            lease.cleanup_failure = Some(failure_kind);
10463            lease.updated_at_unix_ms = unix_time_ms();
10464            lease.snapshot()
10465        };
10466        let _ = self.persist_state();
10467        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10468    }
10469
10470    fn require_worktree_service(
10471        &self,
10472        source_workspace_id: &WorkspaceId,
10473    ) -> Result<(), NodeFailure> {
10474        if self.managed_worktrees.lock()
10475            .unwrap_or_else(|poisoned| poisoned.into_inner())
10476            .lease_for_workspace(source_workspace_id)
10477            .is_some()
10478        {
10479            return Err(failure(
10480                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10481                "managed worktree targets are internal-only workspaces",
10482            ));
10483        }
10484        match self
10485            .worktree_service_modes
10486            .get(source_workspace_id)
10487            .copied()
10488            .unwrap_or(WorktreeServiceMode::Manual)
10489        {
10490            WorktreeServiceMode::Manual => Ok(()),
10491            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10492                NodeFailureCode::UnsupportedSpawnCapability,
10493                "Git worktree service mode is unavailable in this node build",
10494            )),
10495        }
10496    }
10497
10498    fn managed_reservation_conflict(
10499        &self,
10500        workspace_id: Option<&WorkspaceId>,
10501        root: Option<&str>,
10502    ) -> bool {
10503        self.managed_worktrees.lock()
10504            .unwrap_or_else(|poisoned| poisoned.into_inner())
10505            .active_records()
10506            .any(|lease| {
10507                workspace_id.is_some_and(|workspace_id| {
10508                    workspace_id == &lease.source_workspace_id
10509                        || workspace_id == &lease.workspace_id
10510                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10511            })
10512    }
10513
10514    fn reject_managed_reservation(
10515        &self,
10516        workspace_id: Option<&WorkspaceId>,
10517        root: Option<&str>,
10518    ) -> Result<(), NodeFailure> {
10519        if self.managed_reservation_conflict(workspace_id, root) {
10520            Err(failure(
10521                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10522                "managed worktree ownership may only be mutated through its lease cleanup",
10523            ))
10524        } else {
10525            Ok(())
10526        }
10527    }
10528
10529    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10530        self.validate_address(address)?;
10531        self.workspace_root(&address.workspace_id)
10532    }
10533
10534    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10535    /// `require_policy`'s own doc on those two requirements), so they keep
10536    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10537    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10538    /// `false` on the `require_policy` call below for them because a PTY
10539    /// resume never needs those facts, not because this method cannot look
10540    /// them up.
10541    ///
10542    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10543    /// the SAME `binding.runtime_policy` every other requirement reads here
10544    /// (one computed policy, read by every gate). That policy is no longer
10545    /// PTY-terminal-verification-shaped for an ACP session --
10546    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10547    /// `provider_runtime::policy_for_transport` -- so consulting it for
10548    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10549    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10550    /// even though a prompt is an ACP session's only interface). Pipe keeps
10551    /// reading the same PTY-probe-derived value it always has --
10552    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10553    /// its own doc for why).
10554    ///
10555    /// What this method still adds on top, for ACP/Pipe only, is a
10556    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10557    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10558    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10559    /// read off the same live `SessionSnapshot` state `require_acp_session`
10560    /// already reads for the ACP control verbs -- `TurnStarted` observed
10561    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10562    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10563    /// its fate in vendor-specific behaviour this node cannot observe (one
10564    /// agent may queue it, another drop it, another interleave it), so this
10565    /// refuses it outright rather than gambling on that -- a rule with no
10566    /// PTY analogue, which is why PTY does not take this extra precondition.
10567    fn require_session_runtime_policy(
10568        &self,
10569        address: &SessionAddress,
10570        requirement: ProviderRuntimeRequirement,
10571    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10572        let runtime_policy = {
10573            let bindings = self
10574                .session_bindings
10575                .lock()
10576                .unwrap_or_else(|poisoned| poisoned.into_inner());
10577            let binding = bindings
10578                .get(&address.session.instance_id)
10579                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10580            if binding.workspace_id != address.workspace_id {
10581                return Err(failure(
10582                    NodeFailureCode::SessionWorkspaceMismatch,
10583                    "session belongs to another workspace",
10584                ));
10585            }
10586            if binding.generation != address.session.generation {
10587                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10588            }
10589            binding.runtime_policy
10590        };
10591        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10592            let snapshot = self.internal_session_snapshot(address)?;
10593            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10594                && snapshot.provider.lead_activity != ProviderActivity::Idle
10595            {
10596                return Err(failure(
10597                    NodeFailureCode::TurnInFlight,
10598                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10599                ));
10600            }
10601        }
10602        require_policy(runtime_policy, false, false, requirement)
10603            .map(|()| runtime_policy)
10604            .map_err(|_| failure(
10605                NodeFailureCode::UnsupportedCapability,
10606                "session runtime policy does not admit this semantic operation",
10607            ))
10608    }
10609
10610    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10611        let mut bindings = self
10612            .session_bindings
10613            .lock()
10614            .unwrap_or_else(|poisoned| poisoned.into_inner());
10615        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10616            binding.workspace_id == address.workspace_id
10617                && binding.generation == address.session.generation
10618        }) {
10619            let removed = bindings.remove(&address.session.instance_id);
10620            self.clear_terminal_frame_watermark(address);
10621            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10622            return removed;
10623        }
10624        None
10625    }
10626
10627    fn cleanup_session_owned_materialization(
10628        &self,
10629        address: &SessionAddress,
10630        binding: &SessionBinding,
10631    ) -> Result<(), NodeFailure> {
10632        let Some(id) = binding.materialization_id.as_ref() else {
10633            return Ok(());
10634        };
10635        let owner = self
10636            .materializations
10637            .lock()
10638            .unwrap_or_else(|poisoned| poisoned.into_inner())
10639            .get(id)
10640            .map(|record| record.owner().clone());
10641        match owner {
10642            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10643            Some(MaterializationOwner::Session {
10644                incarnation_id,
10645                instance_id,
10646                generation,
10647            }) if incarnation_id == self.incarnation_id
10648                && instance_id == address.session.instance_id
10649                && generation == address.session.generation =>
10650            {
10651                self.cleanup_materialization(id)
10652            }
10653            Some(MaterializationOwner::Session { .. }) => Err(failure(
10654                NodeFailureCode::BackendOperationFailed,
10655                "session environment ownership requires recovery",
10656            )),
10657        }
10658    }
10659
10660    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10661        let mut watermarks = self
10662            .terminal_frame_watermarks
10663            .lock()
10664            .unwrap_or_else(|poisoned| poisoned.into_inner());
10665        if watermarks
10666            .get(&address.session.instance_id)
10667            .is_some_and(|(current, _)| current == address)
10668        {
10669            watermarks.remove(&address.session.instance_id);
10670        }
10671    }
10672
10673    #[cfg(test)]
10674    fn address_for(
10675        &self,
10676        instance_id: AgentInstanceId,
10677        generation: SessionGeneration,
10678    ) -> Option<SessionAddress> {
10679        let binding = self.session_bindings
10680            .lock()
10681            .unwrap_or_else(|poisoned| poisoned.into_inner())
10682            .get(&instance_id)
10683            .cloned()?;
10684        (binding.generation == generation).then_some(SessionAddress {
10685            workspace_id: binding.workspace_id,
10686            session: SessionKey { instance_id, generation },
10687        })
10688    }
10689
10690    fn arm_resume(
10691        &self,
10692        address: &SessionAddress,
10693        command_id: CommandId,
10694        runtime_policy: ProviderRuntimePolicy,
10695    ) -> Result<(), NodeFailure> {
10696        let mut bindings = self
10697            .session_bindings
10698            .lock()
10699            .unwrap_or_else(|poisoned| poisoned.into_inner());
10700        let binding = bindings
10701            .get_mut(&address.session.instance_id)
10702            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10703        if binding.workspace_id != address.workspace_id {
10704            return Err(failure(
10705                NodeFailureCode::SessionWorkspaceMismatch,
10706                "session belongs to another workspace",
10707            ));
10708        }
10709        if binding.generation != address.session.generation {
10710            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10711        }
10712        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10713        Ok(())
10714    }
10715
10716    fn clear_armed_resume(&self, address: &SessionAddress) {
10717        let mut bindings = self
10718            .session_bindings
10719            .lock()
10720            .unwrap_or_else(|poisoned| poisoned.into_inner());
10721        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10722            if binding.workspace_id == address.workspace_id
10723                && binding.generation == address.session.generation
10724            {
10725                binding.pending_resume = None;
10726            }
10727        }
10728    }
10729
10730    fn publish_control(&self, event: ControlEvent) {
10731        let revokes_harness_mcp = matches!(
10732            &event.event,
10733            ControlEventKind::Exited { .. }
10734                | ControlEventKind::Failed { .. }
10735                | ControlEventKind::Removed
10736        );
10737        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10738            let mut bindings = self
10739                .session_bindings
10740                .lock()
10741                .unwrap_or_else(|poisoned| poisoned.into_inner());
10742            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10743                return;
10744            };
10745            let previous_address = SessionAddress {
10746                workspace_id: binding.workspace_id.clone(),
10747                session: SessionKey {
10748                    instance_id: event.instance_id,
10749                    generation: binding.generation,
10750                },
10751            };
10752            let mut resume_command_rejected = false;
10753            if binding.generation == event.generation {
10754                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10755                    let rejected_resume = matches!(
10756                        &event.event,
10757                        ControlEventKind::CommandRejected { .. }
10758                    ) && event.command_id == Some(pending_command_id);
10759                    let resume_failed = matches!(
10760                        &event.event,
10761                        ControlEventKind::ResumeDenied { .. }
10762                            | ControlEventKind::ResumeFailed { .. }
10763                    );
10764                    if pending_generation == binding.generation
10765                        && (rejected_resume || resume_failed)
10766                    {
10767                        resume_command_rejected = rejected_resume;
10768                        binding.pending_resume = None;
10769                    }
10770                }
10771            } else {
10772                let expected = binding.generation.0.checked_add(1);
10773                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10774                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10775                    generation == binding.generation
10776                })
10777                    || expected != Some(event.generation.0)
10778                    || !authorized
10779                {
10780                    return;
10781                }
10782                let (_, _, runtime_policy) = binding
10783                    .pending_resume
10784                    .expect("authorized resume retains its pending runtime policy");
10785                binding.generation = event.generation;
10786                binding.runtime_policy = runtime_policy;
10787                binding.pending_resume = None;
10788            }
10789            (
10790                SessionAddress {
10791                    workspace_id: binding.workspace_id.clone(),
10792                    session: SessionKey {
10793                        instance_id: event.instance_id,
10794                        generation: event.generation,
10795                    },
10796                },
10797                (previous_address.session.generation != event.generation)
10798                    .then_some(previous_address),
10799                binding.record_id.clone(),
10800                binding.runtime_policy.provider_session_identity,
10801                resume_command_rejected,
10802            )
10803        };
10804        if let Some(previous_address) = previous_address.as_ref() {
10805            self.clear_terminal_frame_watermark(previous_address);
10806        }
10807        if let Some(record_id) = record_id {
10808            self.reconcile_managed_record(
10809                &record_id,
10810                &address,
10811                &event,
10812                identity_admitted,
10813                resume_command_rejected,
10814            );
10815        }
10816        // Every state change a session's process lifecycle can reach gets a
10817        // line here: this is the single chokepoint every `ControlEventKind`
10818        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10819        // came from a fresh spawn or a resume. Per-message provider chatter
10820        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10821        // matched here -- that is exactly the per-frame noise this crate
10822        // family's level discipline forbids at INFO.
10823        match &event.event {
10824            ControlEventKind::Running { process_id } => {
10825                if let Some(pid) = process_id {
10826                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10827                }
10828                #[cfg(unix)]
10829                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10830                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10831                }
10832                tracing::info!(
10833                    node_id = %self.node_id,
10834                    workspace_id = %address.workspace_id,
10835                    session = ?address.session,
10836                    pid = ?process_id,
10837                    "session process is running",
10838                );
10839            }
10840            ControlEventKind::Exited { exit_code, forced } => {
10841                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10842                #[cfg(unix)]
10843                if let Some(proxy) = self.signer_proxy.get().cloned() {
10844                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10845                    if proxy.has_session(&key) {
10846                        // Revoke at core off the event path (blocking uplink call).
10847                        std::thread::spawn(move || proxy.release(&key));
10848                    }
10849                }
10850                tracing::info!(
10851                    node_id = %self.node_id,
10852                    workspace_id = %address.workspace_id,
10853                    session = ?address.session,
10854                    exit_code = ?exit_code,
10855                    forced = forced,
10856                    "session process exited",
10857                );
10858            }
10859            ControlEventKind::Failed { message } => {
10860                tracing::warn!(
10861                    node_id = %self.node_id,
10862                    workspace_id = %address.workspace_id,
10863                    session = ?address.session,
10864                    cause = %message,
10865                    "session failed",
10866                );
10867            }
10868            ControlEventKind::CommandRejected { message } => {
10869                tracing::warn!(
10870                    node_id = %self.node_id,
10871                    workspace_id = %address.workspace_id,
10872                    session = ?address.session,
10873                    cause = %message,
10874                    "session command rejected",
10875                );
10876            }
10877            ControlEventKind::Removed => {
10878                tracing::info!(
10879                    node_id = %self.node_id,
10880                    workspace_id = %address.workspace_id,
10881                    session = ?address.session,
10882                    "session removed",
10883                );
10884            }
10885            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10886                tracing::info!(
10887                    node_id = %self.node_id,
10888                    workspace_id = %address.workspace_id,
10889                    session = ?address.session,
10890                    provider_session = ?provider_session,
10891                    "session resume authorized",
10892                );
10893            }
10894            ControlEventKind::Resumed { session: provider_session, process_id } => {
10895                tracing::info!(
10896                    node_id = %self.node_id,
10897                    workspace_id = %address.workspace_id,
10898                    session = ?address.session,
10899                    provider_session = ?provider_session,
10900                    pid = ?process_id,
10901                    "session resumed",
10902                );
10903            }
10904            ControlEventKind::ResumeDenied { reason } => {
10905                tracing::warn!(
10906                    node_id = %self.node_id,
10907                    workspace_id = %address.workspace_id,
10908                    session = ?address.session,
10909                    cause = %reason,
10910                    "session resume denied",
10911                );
10912            }
10913            ControlEventKind::ResumeFailed { message } => {
10914                tracing::warn!(
10915                    node_id = %self.node_id,
10916                    workspace_id = %address.workspace_id,
10917                    session = ?address.session,
10918                    cause = %message,
10919                    "session resume failed",
10920                );
10921            }
10922            _ => {}
10923        }
10924        self.apply_pending_acp_options(&address, &event);
10925        self.apply_acp_auto_permit(&address, &event);
10926        trace_acp_turn(&address, &event);
10927        self.observe_acp_provider_error(&address, &event);
10928        let stream_chunk = agent_stream_chunk(&event);
10929        self.publish(NodeEvent::Control {
10930            address: address.clone(),
10931            event,
10932        });
10933        if let Some(chunk) = stream_chunk {
10934            self.publish_agent_stream_chunk(address.clone(), chunk);
10935        }
10936        if revokes_harness_mcp {
10937            SessionLifecycleHook::on_session_ended(self, &address);
10938        }
10939    }
10940
10941    fn reconcile_managed_record(
10942        &self,
10943        record_id: &SessionRecordId,
10944        address: &SessionAddress,
10945        event: &ControlEvent,
10946        identity_admitted: bool,
10947        resume_command_rejected: bool,
10948    ) {
10949        let _transaction = self
10950            .state_transaction
10951            .lock()
10952            .unwrap_or_else(|poisoned| poisoned.into_inner());
10953        let observed_identity = match &event.event {
10954            ControlEventKind::ProviderEvent {
10955                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10956                ..
10957            } if identity_admitted => Some(identity.clone()),
10958            _ => None,
10959        };
10960        let identity_was_observed = observed_identity.is_some();
10961        let replacement_id = observed_identity
10962            .as_ref()
10963            .and_then(|_| self.allocate_record_id().ok());
10964        let mut upserts = Vec::new();
10965        let mut removals = Vec::new();
10966        let mut rebind = None;
10967        let original_records;
10968        let original_materializations = self
10969            .materializations
10970            .lock()
10971            .unwrap_or_else(|poisoned| poisoned.into_inner())
10972            .clone();
10973        {
10974            let mut records = self
10975                .session_records
10976                .lock()
10977                .unwrap_or_else(|poisoned| poisoned.into_inner());
10978            original_records = records.records.clone();
10979            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10980                return;
10981            };
10982            if let Some(identity) = observed_identity {
10983                let existing_id = records
10984                    .records
10985                    .values()
10986                    .find(|record| {
10987                        record.record_id != *record_id
10988                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10989                                session_registry::same_provider_session(
10990                                    &record.provider,
10991                                    record_identity,
10992                                    &current_snapshot.provider,
10993                                    &identity,
10994                                )
10995                            })
10996                    })
10997                    .map(|record| record.record_id.clone());
10998                if let Some(existing_id) = existing_id {
10999                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11000                        record.mode == current_snapshot.mode
11001                            && record.workspace_id == current_snapshot.workspace_id
11002                            && record.environment_profile == current_snapshot.environment_profile
11003                            && record.bundle == current_snapshot.bundle
11004                            && record.context_id == current_snapshot.context_id
11005                            && record.context == current_snapshot.context
11006                            && record
11007                                .canonical_root
11008                                .as_utf8()
11009                                .zip(current_snapshot.canonical_root.as_utf8())
11010                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11011                    });
11012                    if !scope_matches {
11013                        if let Some(current) = records.records.get_mut(record_id) {
11014                            current.state = ManagedSessionState::Unavailable;
11015                            current.last_error =
11016                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11017                            current.updated_at_unix_ms = unix_time_ms();
11018                            upserts.push(current.clone());
11019                        }
11020                    } else {
11021                        let existing_is_active = records
11022                            .records
11023                            .get(&existing_id)
11024                            .is_some_and(|record| record.active_session.is_some());
11025                        if existing_is_active {
11026                            if let Some(current) = records.records.get_mut(record_id) {
11027                                current.state = ManagedSessionState::Unavailable;
11028                                current.last_error =
11029                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11030                                current.updated_at_unix_ms = unix_time_ms();
11031                                upserts.push(current.clone());
11032                            }
11033                        } else {
11034                            if current_snapshot.provider_session.is_some() {
11035                                if let Some(current) = records.records.get_mut(record_id) {
11036                                    current.active_session = None;
11037                                    current.state = ManagedSessionState::Dormant;
11038                                    current.updated_at_unix_ms = unix_time_ms();
11039                                    upserts.push(current.clone());
11040                                }
11041                            } else {
11042                                records.records.remove(record_id);
11043                                removals.push(record_id.clone());
11044                            }
11045                            if let Some(existing) = records.records.get_mut(&existing_id) {
11046                                existing.provider_session = Some(identity.clone());
11047                                existing.active_session = Some(address.clone());
11048                                existing.state = ManagedSessionState::Live;
11049                                existing.updated_at_unix_ms = unix_time_ms();
11050                                existing.last_error = None;
11051                                upserts.push(existing.clone());
11052                            }
11053                            rebind = Some(existing_id);
11054                        }
11055                    }
11056                } else if current_snapshot
11057                    .provider_session
11058                    .as_ref()
11059                    .is_some_and(|current| {
11060                        !session_registry::same_provider_session(
11061                            &current_snapshot.provider,
11062                            current,
11063                            &current_snapshot.provider,
11064                            &identity,
11065                        )
11066                    })
11067                {
11068                    if let Some(current) = records.records.get_mut(record_id) {
11069                        current.active_session = None;
11070                        if current.bundle.is_some() || current.context.is_some() {
11071                            let had_bundle = current.bundle.is_some();
11072                            current.provider_session = None;
11073                            current.bundle = None;
11074                            current.context_id = None;
11075                            current.context = None;
11076                            current.state = ManagedSessionState::Unavailable;
11077                            current.last_error = Some(if had_bundle {
11078                                "bundle-unavailable".to_owned()
11079                            } else {
11080                                "context-unavailable".to_owned()
11081                            });
11082                        } else {
11083                            current.state = ManagedSessionState::Dormant;
11084                        }
11085                        current.updated_at_unix_ms = unix_time_ms();
11086                        upserts.push(current.clone());
11087                    }
11088                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11089                        if let Some(current) = records.records.get_mut(record_id) {
11090                            current.state = ManagedSessionState::Unavailable;
11091                            current.last_error =
11092                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11093                            current.updated_at_unix_ms = unix_time_ms();
11094                            upserts.push(current.clone());
11095                        }
11096                    } else if let Some(new_record_id) = replacement_id {
11097                        let now = unix_time_ms();
11098                        let replacement = ManagedSessionRecord {
11099                            record_id: new_record_id.clone(),
11100                            display_name: format!(
11101                                "{} #{}",
11102                                current_snapshot.provider.as_str(),
11103                                records
11104                                    .records
11105                                    .values()
11106                                    .filter(|record| {
11107                                        record.provider == current_snapshot.provider
11108                                    })
11109                                    .count()
11110                                    .saturating_add(1),
11111                            ),
11112                            provider: current_snapshot.provider.clone(),
11113                            mode: current_snapshot.mode,
11114                            state: ManagedSessionState::Live,
11115                            workspace_id: current_snapshot.workspace_id,
11116                            canonical_root: current_snapshot.canonical_root,
11117                            provider_session: Some(identity),
11118                            active_session: Some(address.clone()),
11119                            environment_profile: current_snapshot.environment_profile,
11120                            bundle: current_snapshot.bundle,
11121                            context_id: current_snapshot.context_id,
11122                            context: current_snapshot.context,
11123                            // This is a fresh record_id representing the session under its
11124                            // newly-observed identity; it has not yet produced any clean
11125                            // exit of its own, so it starts with no exported pack (the old
11126                            // record, still addressable under its own record_id, keeps
11127                            // whatever exported_context it already had).
11128                            exported_context: None,
11129                            task_binding: current_snapshot.task_binding,
11130                            created_at_unix_ms: now,
11131                            updated_at_unix_ms: now,
11132                            last_error: None,
11133                        };
11134                        records
11135                            .records
11136                            .insert(new_record_id.clone(), replacement.clone());
11137                        upserts.push(replacement);
11138                        rebind = Some(new_record_id);
11139                    } else if let Some(current) = records.records.get_mut(record_id) {
11140                        current.state = ManagedSessionState::Unavailable;
11141                        current.last_error =
11142                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11143                        upserts.push(current.clone());
11144                    }
11145                } else if let Some(current) = records.records.get_mut(record_id) {
11146                    current.provider_session = Some(identity);
11147                    current.active_session = Some(address.clone());
11148                    current.state = ManagedSessionState::Live;
11149                    current.updated_at_unix_ms = unix_time_ms();
11150                    current.last_error = None;
11151                    upserts.push(current.clone());
11152                }
11153            } else if let Some(current) = records.records.get_mut(record_id) {
11154                if resume_command_rejected {
11155                    current.active_session = None;
11156                    current.state = detached_record_state(current);
11157                    current.updated_at_unix_ms = unix_time_ms();
11158                    current.last_error =
11159                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11160                    upserts.push(current.clone());
11161                } else {
11162                    match &event.event {
11163                    ControlEventKind::Running { .. }
11164                    | ControlEventKind::ResumeAuthorized { .. }
11165                    | ControlEventKind::Resumed { .. } => {
11166                        current.active_session = Some(address.clone());
11167                        current.state = active_record_state(
11168                            identity_admitted,
11169                            current.provider_session.is_some(),
11170                        );
11171                        current.updated_at_unix_ms = unix_time_ms();
11172                        current.last_error = None;
11173                        upserts.push(current.clone());
11174                    }
11175                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11176                        current.active_session = None;
11177                        current.state = detached_record_state(current);
11178                        current.updated_at_unix_ms = unix_time_ms();
11179                        upserts.push(current.clone());
11180                    }
11181                    ControlEventKind::Failed { message }
11182                    | ControlEventKind::ResumeFailed { message } => {
11183                        current.active_session = None;
11184                        current.state = detached_record_state(current);
11185                        current.updated_at_unix_ms = unix_time_ms();
11186                        current.last_error = Some(
11187                            session_registry::sanitized_record_error_summary(message),
11188                        );
11189                        upserts.push(current.clone());
11190                    }
11191                    ControlEventKind::ResumeDenied { reason } => {
11192                        current.active_session = None;
11193                        current.state = detached_record_state(current);
11194                        current.updated_at_unix_ms = unix_time_ms();
11195                        current.last_error = Some(
11196                            session_registry::sanitized_record_error_summary(reason),
11197                        );
11198                        upserts.push(current.clone());
11199                    }
11200                        _ => {}
11201                    }
11202                }
11203            }
11204        }
11205        let materialization_owner_record = identity_was_observed.then(|| {
11206            rebind.clone().unwrap_or_else(|| record_id.clone())
11207        }).and_then(|candidate| {
11208            self.session_records
11209                .lock()
11210                .unwrap_or_else(|poisoned| poisoned.into_inner())
11211                .records
11212                .get(&candidate)
11213                .filter(|record| {
11214                    record.provider_session.is_some()
11215                        && record.active_session.as_ref() == Some(address)
11216                })
11217                .map(|_| candidate)
11218        });
11219        if let Some(owner_record_id) = materialization_owner_record {
11220            let materialization_id = self
11221                .session_bindings
11222                .lock()
11223                .unwrap_or_else(|poisoned| poisoned.into_inner())
11224                .get(&address.session.instance_id)
11225                .and_then(|binding| binding.materialization_id.clone());
11226            if let Some(materialization_id) = materialization_id {
11227                let mut transfer_failed = false;
11228                {
11229                    let mut materializations = self
11230                        .materializations
11231                        .lock()
11232                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11233                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11234                        match ownership.owner() {
11235                            MaterializationOwner::Session {
11236                                incarnation_id,
11237                                instance_id,
11238                                generation,
11239                            } if *incarnation_id == self.incarnation_id
11240                                && *instance_id == address.session.instance_id
11241                                && *generation == address.session.generation =>
11242                            {
11243                                transfer_failed = ownership
11244                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11245                                    .is_err();
11246                            }
11247                            MaterializationOwner::Record { record_id }
11248                                if record_id == &owner_record_id => {}
11249                            MaterializationOwner::Record { record_id } => {
11250                                let expected = record_id.clone();
11251                                transfer_failed = ownership
11252                                    .transfer_record_owner(
11253                                        &expected,
11254                                        owner_record_id.clone(),
11255                                        unix_time_ms(),
11256                                    )
11257                                    .is_err();
11258                            }
11259                            _ => transfer_failed = true,
11260                        }
11261                        if transfer_failed {
11262                            let _ = ownership.mark_recovery_required(unix_time_ms());
11263                        }
11264                    } else {
11265                        transfer_failed = true;
11266                    }
11267                }
11268                if transfer_failed {
11269                    if let Some(record) = self
11270                        .session_records
11271                        .lock()
11272                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11273                        .records
11274                        .get_mut(&owner_record_id)
11275                    {
11276                        record.state = ManagedSessionState::Unavailable;
11277                        record.last_error = Some("environment-profile-unavailable".to_owned());
11278                        record.updated_at_unix_ms = unix_time_ms();
11279                        upserts.push(record.clone());
11280                    }
11281                }
11282            }
11283        }
11284        if let Some(new_record_id) = rebind.clone() {
11285            if let Some(binding) = self
11286                .session_bindings
11287                .lock()
11288                .unwrap_or_else(|poisoned| poisoned.into_inner())
11289                .get_mut(&address.session.instance_id)
11290            {
11291                binding.record_id = Some(new_record_id);
11292            }
11293        }
11294        if upserts.is_empty() && removals.is_empty() {
11295            return;
11296        }
11297        if self.persist_state_locked().is_err() {
11298            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11299            let mut records = self
11300                .session_records
11301                .lock()
11302                .unwrap_or_else(|poisoned| poisoned.into_inner());
11303            records.records = original_records;
11304            *self
11305                .materializations
11306                .lock()
11307                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11308            if let Some(current) = records.records.get_mut(record_id) {
11309                current.state = ManagedSessionState::Unavailable;
11310                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11311                current.updated_at_unix_ms = unix_time_ms();
11312                upserts = vec![current.clone()];
11313            } else {
11314                upserts.clear();
11315            }
11316            removals.clear();
11317            drop(records);
11318            if let Some(binding) = self
11319                .session_bindings
11320                .lock()
11321                .unwrap_or_else(|poisoned| poisoned.into_inner())
11322                .get_mut(&address.session.instance_id)
11323            {
11324                binding.record_id = Some(record_id.clone());
11325            }
11326            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11327        }
11328        for record in upserts {
11329            self.publish_record(record);
11330        }
11331        for record_id in removals {
11332            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11333        }
11334    }
11335
11336    fn snapshot(&self) -> NodeSnapshot {
11337        let control = self.handle.snapshot();
11338        let bindings = self
11339            .session_bindings
11340            .lock()
11341            .unwrap_or_else(|poisoned| poisoned.into_inner())
11342            .clone();
11343        let workspace_roots = self
11344            .workspaces
11345            .read()
11346            .unwrap_or_else(|poisoned| poisoned.into_inner())
11347            .clone();
11348        let environment_profiles = self
11349            .environment_profiles
11350            .read()
11351            .unwrap_or_else(|poisoned| poisoned.into_inner());
11352        let mut workspaces = workspace_roots
11353            .iter()
11354            .map(|(workspace_id, canonical_root)| {
11355                (
11356                    workspace_id.clone(),
11357                    WorkspaceSnapshot {
11358                        workspace_id: workspace_id.clone(),
11359                        canonical_root: opaque_windows_path(canonical_root.clone()),
11360                        sessions: Vec::new(),
11361                        worktree_service_mode: Some(
11362                            self.worktree_service_modes
11363                                .get(workspace_id)
11364                                .copied()
11365                                .unwrap_or(WorktreeServiceMode::Manual),
11366                        ),
11367                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11368                            profiles: self
11369                                .managed_worktree_profiles
11370                                .get(workspace_id)
11371                                .into_iter()
11372                                .flat_map(BTreeMap::values)
11373                                .map(|profile| ManagedWorktreeProfileSummary {
11374                                    id: profile.profile_id().clone(),
11375                                    revision: profile.revision().clone(),
11376                                    retention: profile.retention(),
11377                                })
11378                                .collect(),
11379                        }),
11380                    },
11381                )
11382            })
11383            .collect::<BTreeMap<_, _>>();
11384        let mut agent_progress = Vec::new();
11385        for session in &control.sessions {
11386            let Some(binding) = bindings.get(&session.instance_id) else {
11387                continue;
11388            };
11389            if binding.generation != session.generation {
11390                continue;
11391            }
11392            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11393                let address = SessionAddress {
11394                    workspace_id: binding.workspace_id.clone(),
11395                    session: SessionKey {
11396                        instance_id: session.instance_id,
11397                        generation: session.generation,
11398                    },
11399                };
11400                if let Some(progress) =
11401                    agent_progress_from_provider_snapshot(address, &session.provider)
11402                {
11403                    agent_progress.push(progress);
11404                }
11405                workspace.sessions.push(session.clone());
11406            }
11407        }
11408        NodeSnapshot {
11409            node_id: self.node_id.clone(),
11410            enabled_providers: self.enabled_providers.clone(),
11411            provider_runtime_statuses: {
11412                let updates = self
11413                    .provider_runtime_status_updates
11414                    .read()
11415                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11416                ProviderRuntimeStatuses::new(
11417                    self.provider_runtime_statuses
11418                        .iter()
11419                        .filter(|status| !updates.contains_key(status.provider()))
11420                        .cloned()
11421                        .chain(updates.values().cloned()),
11422                )
11423                .expect("refreshed provider runtime inventory remains bounded and unique")
11424            },
11425            workspaces: workspaces.into_values().collect(),
11426            session_records: self
11427                .session_records
11428                .lock()
11429                .unwrap_or_else(|poisoned| poisoned.into_inner())
11430                .records
11431                .values()
11432                .cloned()
11433                .collect(),
11434            managed_worktrees: self.managed_worktrees
11435                .lock()
11436                .unwrap_or_else(|poisoned| poisoned.into_inner())
11437                .snapshots(),
11438            launch_inventory: Some(LaunchInventory {
11439                spawn_profiles: Some(
11440                    self.spawn_profiles
11441                        .iter()
11442                        .filter_map(|profile| {
11443                            resolved_spawn_profile_summary(profile, &environment_profiles)
11444                        })
11445                        .collect(),
11446                ),
11447                bundles: Some(
11448                    self.bundle_catalog
11449                        .read()
11450                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11451                        .iter()
11452                        .map(NodeBundle::receipt)
11453                        .collect(),
11454                ),
11455                // Opaque station allowlist ids from empty-default catalog /
11456                // optional startup loader. Never secrets. Dig2 probe stays stub.
11457                network_allowlists: {
11458                    let catalog = self
11459                        .network_allowlist_catalog
11460                        .read()
11461                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11462                    if catalog.is_empty() {
11463                        None
11464                    } else {
11465                        Some(catalog.ids().cloned().collect())
11466                    }
11467                },
11468            }),
11469            agent_progress,
11470        }
11471    }
11472
11473    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11474        assert!(
11475            !matches!(
11476                &event,
11477                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11478            ),
11479            "terminal frame and agent stream events must use their own dedicated live channel",
11480        );
11481        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11482        let sequence = history
11483            .last_sequence
11484            .checked_add(1)
11485            .expect("node event sequence exhausted");
11486        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11487            if let Some(evicted) = history.events.pop_front() {
11488                history.replay_floor_sequence = evicted
11489                    .sequence
11490                    .checked_add(1)
11491                    .expect("node replay floor sequence exhausted");
11492                history.removed_record_providers.remove(&evicted.sequence);
11493            }
11494        }
11495        match &event {
11496            NodeEvent::HarnessMcpReadCall { .. } => {
11497                panic!("transient harness MCP calls must not enter durable event history")
11498            }
11499            NodeEvent::SessionRecordUpserted { record } => {
11500                history
11501                    .record_providers
11502                    .insert(record.record_id.clone(), record.provider.clone());
11503            }
11504            NodeEvent::SessionRecordRemoved { record_id } => {
11505                if let Some(provider) = history.record_providers.remove(record_id) {
11506                    history.removed_record_providers.insert(sequence, provider);
11507                }
11508            }
11509            NodeEvent::ManagedWorktreeUpserted { .. }
11510            | NodeEvent::ManagedWorktreeRemoved { .. }
11511            | NodeEvent::Control { .. }
11512            | NodeEvent::ControllerChanged { .. }
11513            | NodeEvent::SessionRecordHistorySummarized { .. }
11514            | NodeEvent::WorkspaceAdded { .. }
11515            | NodeEvent::WorkspaceRemoved { .. }
11516            | NodeEvent::ResyncRequired { .. }
11517            | NodeEvent::AgentStream { .. }
11518            | NodeEvent::TerminalFrame { .. } => {}
11519        }
11520        let envelope = NodeEventEnvelope { sequence, event };
11521        history.events.push_back(envelope.clone());
11522        history.last_sequence = sequence;
11523        let _ = self.event_tx.send(envelope.clone());
11524        drop(history);
11525        envelope
11526    }
11527
11528    /// Publishes a transient (never durable) `NodeEvent` -- today only
11529    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11530    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11531    /// why this must never ride the shared `event_tx` bus). Returns whether
11532    /// the send reached at least one subscriber: `broadcast::Sender::send`
11533    /// fails in exactly one way, no active receiver at that instant, and
11534    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11535    /// return to fail the pending call immediately rather than leave it to
11536    /// expire silently at `LOCAL_READ_TIMEOUT`/
11537    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11538    fn publish_transient(&self, event: NodeEvent) -> bool {
11539        assert!(event.requires_harness_mcp_proxy_capability());
11540        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11541        let envelope = NodeEventEnvelope { sequence: 0, event };
11542        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11543            Ok(_) => true,
11544            Err(broadcast::error::SendError(undelivered)) => {
11545                if let Some(NodeEventEnvelope {
11546                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11547                    ..
11548                }) = undelivered.first() {
11549                    // `request.content_type` only, never a per-tool label --
11550                    // this crate does not decode `request.body` and does not
11551                    // hold `hatchery-harness-api` (Nested Control Plane
11552                    // doctrine, Law 3; see this crate's dependency on
11553                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11554                    tracing::warn!(
11555                        call_id = call_id.as_str(),
11556                        content_type = ?request.content_type,
11557                        cause = "no-subscribers",
11558                        "harness MCP proxy event could not be published",
11559                    );
11560                }
11561                false
11562            }
11563        }
11564    }
11565
11566    fn publish_terminal_frames(&self) {
11567        let snapshot = self.handle.snapshot();
11568        let bindings = self
11569            .session_bindings
11570            .lock()
11571            .unwrap_or_else(|poisoned| poisoned.into_inner());
11572        let candidates = snapshot
11573            .sessions
11574            .iter()
11575            .filter_map(|session| {
11576                let binding = bindings.get(&session.instance_id)?;
11577                if binding.generation != session.generation {
11578                    return None;
11579                }
11580                let frame = session.terminal_frame.clone()?;
11581                crate::session_signals::feed(
11582                    binding.workspace_id.as_str(),
11583                    session.instance_id.0,
11584                    session.agent_id.as_str(),
11585                    &frame.contents,
11586                );
11587                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11588                let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11589                if let Some(act) = act {
11590                    provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11591                }
11592                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11593                Some((
11594                    SessionAddress {
11595                        workspace_id: binding.workspace_id.clone(),
11596                        session: SessionKey {
11597                            instance_id: session.instance_id,
11598                            generation: session.generation,
11599                        },
11600                    },
11601                    frame,
11602                ))
11603            })
11604            .collect();
11605        drop(bindings);
11606        self.publish_terminal_frame_candidates(candidates);
11607    }
11608
11609    fn publish_terminal_frame_candidates(
11610        &self,
11611        candidates: Vec<(SessionAddress, TerminalFrame)>,
11612    ) {
11613        let mut watermarks = self
11614            .terminal_frame_watermarks
11615            .lock()
11616            .unwrap_or_else(|poisoned| poisoned.into_inner());
11617        let mut events = Vec::with_capacity(candidates.len());
11618        for (address, frame) in candidates {
11619            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11620            let advanced = watermarks
11621                .get(&address.session.instance_id)
11622                .map_or(true, |(current_address, sequence)| {
11623                    current_address != &address || frame.sequence > *sequence
11624                });
11625            if !advanced {
11626                continue;
11627            }
11628            if is_first_frame {
11629                // Do NOT sessionize on first PTY output: a session is a
11630                // provider conversation id, not bytes on the wire. Drafts
11631                // leave the provisional map only when HQ attaches
11632                // (Resize / TerminalBytes). The attach-never-arrived timer
11633                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11634                // disconnect grace starts only on a real client WS close.
11635                // This is the first PTY output this node has observed for
11636                // this session -- a one-time event per spawn, not per-frame
11637                // chatter (the watermark makes sure of that). A session that
11638                // reaches this line with no controller attached anywhere on
11639                // the node is running unobserved: exactly how processes from
11640                // repeated spawn attempts went orphaned for hours without a
11641                // single line about it.
11642                if self.controller_state().is_none() {
11643                    tracing::warn!(
11644                        node_id = %self.node_id,
11645                        workspace_id = %address.workspace_id,
11646                        session = ?address.session,
11647                        terminal_size = ?frame.size,
11648                        "session producing output with no controller attached",
11649                    );
11650                } else {
11651                    tracing::info!(
11652                        node_id = %self.node_id,
11653                        workspace_id = %address.workspace_id,
11654                        session = ?address.session,
11655                        terminal_size = ?frame.size,
11656                        "session produced its first output",
11657                    );
11658                }
11659            }
11660            watermarks.insert(
11661                address.session.instance_id,
11662                (address.clone(), frame.sequence),
11663            );
11664            if let Some((at, reported)) = self
11665                .pty_input_marks
11666                .lock()
11667                .unwrap_or_else(|poisoned| poisoned.into_inner())
11668                .get_mut(&address.session.instance_id)
11669            {
11670                // First frame after an input: PTY write -> output published.
11671                if !*reported && at.elapsed() < Duration::from_secs(5) {
11672                    *reported = true;
11673                    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");
11674                }
11675            }
11676            events.push(NodeEvent::TerminalFrame { address, frame });
11677        }
11678        drop(watermarks);
11679        if events.is_empty() {
11680            return;
11681        }
11682        let mut history = self
11683            .history
11684            .lock()
11685            .unwrap_or_else(|poisoned| poisoned.into_inner());
11686        let mut envelopes = Vec::with_capacity(events.len());
11687        for event in events {
11688            let sequence = history
11689                .last_sequence
11690                .checked_add(1)
11691                .expect("node event sequence exhausted");
11692            history.last_sequence = sequence;
11693            envelopes.push(NodeEventEnvelope { sequence, event });
11694        }
11695        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11696        drop(history);
11697    }
11698
11699    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11700    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11701    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11702    /// counter every other event kind shares, never a counter of its own --
11703    /// `queue_connection_event`/`queue_connection_event_batch` compare
11704    /// `envelope.sequence` against one `discard_events_through` watermark
11705    /// regardless of which broadcast channel it arrived on, so a
11706    /// per-channel counter would desynchronize that comparison.
11707    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11708        let mut history = self
11709            .history
11710            .lock()
11711            .unwrap_or_else(|poisoned| poisoned.into_inner());
11712        let sequence = history
11713            .last_sequence
11714            .checked_add(1)
11715            .expect("node event sequence exhausted");
11716        history.last_sequence = sequence;
11717        drop(history);
11718        // Debug, not info/warn -- this fires on every single chunk, but it
11719        // is exactly the counter the next drop-measurement pass needs: what
11720        // the node actually emitted (`source_sequence`, `kind`) versus what
11721        // a harness subscriber's own forwarder later logs having received
11722        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11723        // event"), so a gap between the two logs names itself instead of
11724        // needing a new counter added ad hoc.
11725        tracing::debug!(
11726            source_sequence = chunk.source_sequence,
11727            kind = agent_stream_chunk_kind_label(&chunk.kind),
11728            envelope_sequence = sequence,
11729            "publishing agent stream chunk",
11730        );
11731        let envelope = NodeEventEnvelope {
11732            sequence,
11733            event: NodeEvent::AgentStream { address, chunk },
11734        };
11735        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11736    }
11737
11738    fn current_sequence(&self) -> u64 {
11739        self.history
11740            .lock()
11741            .unwrap_or_else(|poisoned| poisoned.into_inner())
11742            .last_sequence
11743    }
11744
11745    fn controller_state(&self) -> Option<ControllerState> {
11746        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11747        let lease = controller.as_ref().copied()?;
11748        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11749        if remaining.is_zero() {
11750            *controller = None;
11751            return None;
11752        }
11753        Some(ControllerState {
11754            connection_id: lease.connection_id,
11755            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11756        })
11757    }
11758
11759    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11760        if role != ClientRole::Operator {
11761            tracing::warn!(
11762                node_id = %self.node_id,
11763                connection_id,
11764                "controller lease rejected: observer connections cannot acquire control",
11765            );
11766            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11767        }
11768        let lease_ms = if lease_ms == 0 {
11769            DEFAULT_CONTROLLER_LEASE_MS
11770        } else {
11771            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11772        };
11773        let now = Instant::now();
11774        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11775        if let Some(active) = controller.as_ref() {
11776            if active.connection_id != connection_id && active.expires_at > now {
11777                tracing::warn!(
11778                    node_id = %self.node_id,
11779                    connection_id,
11780                    holder_connection_id = active.connection_id,
11781                    "controller lease rejected: another operator holds it",
11782                );
11783                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11784            }
11785        }
11786        // A reconnect (this connection already holds the lease, renewing
11787        // before expiry) happens on a periodic cadence; logging it at INFO
11788        // every renewal would be exactly the heartbeat chatter this crate
11789        // family's level discipline forbids. A genuine attach -- the
11790        // controller identity actually changing -- is a state change and
11791        // gets INFO; a same-connection renewal gets DEBUG.
11792        let is_new_attach = match controller.as_ref() {
11793            Some(active) => active.connection_id != connection_id,
11794            None => true,
11795        };
11796        *controller = Some(ControllerLease {
11797            connection_id,
11798            expires_at: now + Duration::from_millis(lease_ms),
11799        });
11800        drop(controller);
11801        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11802        if is_new_attach {
11803            tracing::info!(
11804                node_id = %self.node_id,
11805                connection_id,
11806                lease_ms,
11807                "controller lease acquired",
11808            );
11809        } else {
11810            tracing::debug!(
11811                node_id = %self.node_id,
11812                connection_id,
11813                lease_ms,
11814                "controller lease renewed",
11815            );
11816        }
11817        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11818        Ok(state)
11819    }
11820
11821    fn release_controller(&self, connection_id: u64) -> bool {
11822        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11823        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11824        if released {
11825            *controller = None;
11826        }
11827        drop(controller);
11828        if released {
11829            tracing::info!(
11830                node_id = %self.node_id,
11831                connection_id,
11832                "controller lease released",
11833            );
11834            self.publish(NodeEvent::ControllerChanged { controller: None });
11835        }
11836        released
11837    }
11838
11839    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11840    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11841    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11842    /// provider session; it only cancels the orphan timer. Disconnect/reload
11843    /// draft kills are owned by HQ and arm only on a real client WS close.
11844    /// ACP path of launch options: once the agent advertises its config options, write the
11845    /// pending model / effort through session/set_config_option (session/set_model when the
11846    /// agent has no model option). Gaps are logged, never forced.
11847    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11848        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11849            return;
11850        };
11851        let id = address.session.instance_id;
11852        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11853            return;
11854        };
11855        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11856            .iter()
11857            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11858            .collect();
11859        let (writes, set_model, effort_gap) =
11860            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11861        if set_model {
11862            if let Some(model_id) = model.clone() {
11863                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11864                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11865                }
11866            }
11867        }
11868        for (option_id, value_json) in writes {
11869            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11870                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11871            }
11872        }
11873        if effort_gap {
11874            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11875        }
11876        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11877    }
11878
11879    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11880    /// answer every session/request_permission with approve.
11881    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11882        let id = address.session.instance_id;
11883        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11884        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11885            crate::auto_permit::forget_acp(id.0);
11886            return;
11887        }
11888        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11889        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11890            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11891        }
11892        match provider_event {
11893            ProviderEvent::ModeChanged { mode_id, available } => {
11894                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11895                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11896                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11897                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11898                    }
11899                }
11900            }
11901            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11902                match self.dispatch(ControlCommand::ResolveInteraction {
11903                    instance_id: id,
11904                    generation: address.session.generation,
11905                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11906                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11907                }) {
11908                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11909                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11910                }
11911            }
11912            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11913            // must not drop the auto-permit registration; that happens on process exit below.
11914            _ => {}
11915        }
11916    }
11917
11918    /// ACP side of the provider error classifier: remember the turn's prompt, reset on a clean
11919    /// end, classify a turn that ended with a provider error (or a provider `Error` event).
11920    fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
11921        let id = address.session.instance_id.0;
11922        let found = match &event.event {
11923            ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11924                self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
11925                return;
11926            }
11927            ControlEventKind::ProviderEvent { event: e, .. } => match e {
11928                ProviderEvent::TurnStarted { prompt } => {
11929                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
11930                    return;
11931                }
11932                ProviderEvent::SessionEnded { is_error: false, .. } => {
11933                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ok(id);
11934                    return;
11935                }
11936                ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => crate::provider_error::classify_message(message, vendor_code.as_deref()),
11937                ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11938                _ => return,
11939            },
11940            _ => return,
11941        };
11942        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11943        let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11944        if let Some(act) = act {
11945            provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11946        }
11947    }
11948
11949    /// Run provider-error retries whose backoff is over (called from the drive loop).
11950    fn run_due_provider_retries(&self) {
11951        let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11952        for (id, how, attempt) in due {
11953            let instance_id = AgentInstanceId(id);
11954            let r = match how {
11955                crate::provider_error::Retry::Pty => self
11956                    .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11957                    .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11958                crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11959                    instance_id,
11960                    action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11961                }),
11962            };
11963            match r {
11964                Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11965                Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11966            }
11967        }
11968    }
11969
11970    fn mark_spawn_provisional(&self, address: SessionAddress) {
11971        let id = address.session.instance_id;
11972        self.provisional_spawns
11973            .lock()
11974            .unwrap_or_else(|poisoned| poisoned.into_inner())
11975            .insert(id, (address, Instant::now()));
11976        tracing::info!(
11977            node_id = %self.node_id,
11978            instance_id = id.0,
11979            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11980            "draft spawn; waiting for HQ view",
11981        );
11982    }
11983
11984    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11985        let removed = self
11986            .provisional_spawns
11987            .lock()
11988            .unwrap_or_else(|poisoned| poisoned.into_inner())
11989            .remove(&instance_id);
11990        if removed.is_some() {
11991            tracing::info!(
11992                node_id = %self.node_id,
11993                instance_id = instance_id.0,
11994                "draft spawn claimed by HQ view",
11995            );
11996        }
11997    }
11998
11999    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12000        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12001        let mut map = self
12002            .provisional_spawns
12003            .lock()
12004            .unwrap_or_else(|poisoned| poisoned.into_inner());
12005        let due: Vec<AgentInstanceId> = map
12006            .iter()
12007            .filter(|(_, (_, at))| at.elapsed() >= grace)
12008            .map(|(id, _)| *id)
12009            .collect();
12010        due.into_iter()
12011            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12012            .collect()
12013    }
12014
12015    async fn reap_provisional_spawn(&self, address: SessionAddress) {
12016        let instance_id = address.session.instance_id;
12017        tracing::warn!(
12018            node_id = %self.node_id,
12019            instance_id = instance_id.0,
12020            workspace = %address.workspace_id,
12021            reason = "attach_never_arrived",
12022            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12023            "draft spawn never viewed; stopping PTY",
12024        );
12025        let _ = self.dispatch(ControlCommand::Stop {
12026            instance_id,
12027            force: true,
12028        });
12029        let _ = self.remove_session(&address).await;
12030    }
12031
12032    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12033        if role != ClientRole::Operator {
12034            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12035        }
12036        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12037        let now = Instant::now();
12038        match controller.as_ref().copied() {
12039            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12040            Some(active) if active.expires_at <= now => {
12041                *controller = None;
12042                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12043            }
12044            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12045        }
12046    }
12047
12048    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12049        if !self
12050            .workspaces
12051            .read()
12052            .unwrap_or_else(|poisoned| poisoned.into_inner())
12053            .contains_key(&address.workspace_id)
12054        {
12055            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12056        }
12057        let binding = self
12058            .session_bindings
12059            .lock()
12060            .unwrap_or_else(|poisoned| poisoned.into_inner())
12061            .get(&address.session.instance_id)
12062            .cloned()
12063            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12064        if binding.workspace_id != address.workspace_id {
12065            return Err(failure(
12066                NodeFailureCode::SessionWorkspaceMismatch,
12067                "session belongs to another workspace",
12068            ));
12069        }
12070        if binding.generation != address.session.generation {
12071            return Err(failure(
12072                NodeFailureCode::StaleGeneration,
12073                &format!(
12074                    "stale session generation: expected {}, received {}",
12075                    binding.generation.0,
12076                    address.session.generation.0,
12077                ),
12078            ));
12079        }
12080        let snapshot = self.handle.snapshot();
12081        let Some(current) = snapshot
12082            .sessions
12083            .iter()
12084            .find(|item| item.instance_id == address.session.instance_id)
12085        else {
12086            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12087        };
12088        if current.generation != address.session.generation {
12089            return Err(failure(
12090                NodeFailureCode::StaleGeneration,
12091                &format!(
12092                    "stale session generation: expected {}, received {}",
12093                    current.generation.0,
12094                    address.session.generation.0,
12095                ),
12096            ));
12097        }
12098        Ok(current.agent_id.clone())
12099    }
12100
12101    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12102        self.dispatch_envelope(self.prepare_command(command))
12103    }
12104
12105    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12106        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12107        CommandEnvelope {
12108            id: CommandId(id),
12109            command,
12110        }
12111    }
12112
12113    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12114        let command_id = envelope.id;
12115        self.handle
12116            .dispatch(envelope)
12117            .map_err(|error| match error {
12118                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12119                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12120            })?;
12121        Ok(command_id)
12122    }
12123
12124    /// Records the kernel's own reason for asynchronously refusing
12125    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12126    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12127    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12128    /// first, and if the map is still at capacity after that, the single
12129    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12130    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12131    /// is always the oldest.
12132    fn record_rejected_command(
12133        &self,
12134        command_id: CommandId,
12135        message: String,
12136        unsupported_transport: bool,
12137    ) {
12138        let now = Instant::now();
12139        let mut reasons = self.rejected_command_reasons.lock()
12140            .unwrap_or_else(|poisoned| poisoned.into_inner());
12141        reasons.retain(|_, reason| reason.expires_at > now);
12142        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12143            if let Some(&oldest) = reasons.keys().next() {
12144                reasons.remove(&oldest);
12145            }
12146        }
12147        reasons.insert(
12148            command_id,
12149            RejectedCommandReason {
12150                message,
12151                unsupported_transport,
12152                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12153            },
12154        );
12155    }
12156
12157    /// Removes and returns the kernel's reason for refusing `command_id`,
12158    /// if the drive loop has recorded one and it has not expired -- see
12159    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12160    /// wait loop calls this instead of blindly polling out its whole
12161    /// deadline when the command it is waiting on turns out to have been
12162    /// refused rather than merely slow.
12163    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12164        let now = Instant::now();
12165        let mut reasons = self.rejected_command_reasons.lock()
12166            .unwrap_or_else(|poisoned| poisoned.into_inner());
12167        match reasons.remove(&command_id) {
12168            Some(reason) if reason.expires_at > now => Some(reason),
12169            _ => None,
12170        }
12171    }
12172
12173    async fn dispatch_bounded(
12174        &self,
12175        command: ControlCommand,
12176        timeout_duration: Duration,
12177    ) -> Result<CommandId, NodeFailure> {
12178        let deadline = Instant::now() + timeout_duration;
12179        loop {
12180            match self.dispatch(command.clone()) {
12181                Ok(command_id) => return Ok(command_id),
12182                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12183                    sleep(Duration::from_millis(2)).await;
12184                }
12185                Err(error) => return Err(error),
12186            }
12187        }
12188    }
12189
12190    fn internal_session_snapshot(
12191        &self,
12192        address: &SessionAddress,
12193    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12194        self.validate_address(address)?;
12195        self.handle
12196            .snapshot()
12197            .sessions
12198            .clone()
12199            .into_iter()
12200            .find(|session| {
12201                session.instance_id == address.session.instance_id
12202                    && session.generation == address.session.generation
12203            })
12204            .ok_or_else(|| {
12205                failure(
12206                    NodeFailureCode::UnknownSession,
12207                    "session snapshot is unavailable",
12208                )
12209            })
12210    }
12211
12212    /// Confirms `address` names a live session spawned over the ACP
12213    /// transport and returns its current snapshot -- the gate every
12214    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12215    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12216    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12217    /// the session's own recorded `TransportKind` does, and that lives on
12218    /// the live snapshot, not the binding.
12219    fn require_acp_session(
12220        &self,
12221        address: &SessionAddress,
12222    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12223        let snapshot = self.internal_session_snapshot(address)?;
12224        if snapshot.transport != TransportKind::Acp {
12225            return Err(failure(
12226                NodeFailureCode::UnsupportedTransport,
12227                "this verb requires a session spawned over the ACP transport",
12228            ));
12229        }
12230        Ok(snapshot)
12231    }
12232
12233    async fn dispatch_history_bounded(
12234        &self,
12235        address: &SessionAddress,
12236        command: ControlCommand,
12237        operation: HistoryOperation,
12238    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12239        self.validate_address(address)?;
12240        let started_after = self.current_sequence();
12241        let command_id = self
12242            .dispatch_bounded(
12243                command,
12244                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12245            )
12246            .await?;
12247        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12248        let mut scan_after = started_after;
12249        let mut accepted_after = None;
12250        loop {
12251            let events = self
12252                .history
12253                .lock()
12254                .unwrap_or_else(|poisoned| poisoned.into_inner())
12255                .events
12256                .iter()
12257                .filter(|envelope| envelope.sequence > scan_after)
12258                .cloned()
12259                .collect::<Vec<_>>();
12260            for envelope in events {
12261                scan_after = scan_after.max(envelope.sequence);
12262                let NodeEvent::Control {
12263                    address: event_address,
12264                    event,
12265                } = envelope.event
12266                else {
12267                    continue;
12268                };
12269                if event_address != *address {
12270                    continue;
12271                }
12272                if event.command_id == Some(command_id) {
12273                    match &event.event {
12274                        ControlEventKind::CommandRejected { .. } => {
12275                            return Err(failure(
12276                                NodeFailureCode::InvalidRequest,
12277                                "history request was rejected",
12278                            ));
12279                        }
12280                        ControlEventKind::HistoryRequested {
12281                            operation: requested,
12282                            ..
12283                        } if requested == &operation => {
12284                            accepted_after = Some(envelope.sequence);
12285                            continue;
12286                        }
12287                        _ => {}
12288                    }
12289                }
12290                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12291                    match (&operation, &event.event) {
12292                        (
12293                            HistoryOperation::Discover { .. },
12294                            ControlEventKind::HistoryDiscovered { .. },
12295                        )
12296                        | (
12297                            HistoryOperation::Load { .. },
12298                            ControlEventKind::HistoryLoaded { .. },
12299                        ) => return self.internal_session_snapshot(address),
12300                        (_, ControlEventKind::HistoryFailed { .. }) => {
12301                            return Err(failure(
12302                                NodeFailureCode::BackendOperationFailed,
12303                                "history operation failed",
12304                            ));
12305                        }
12306                        _ => {}
12307                    }
12308                }
12309            }
12310            if Instant::now() >= deadline {
12311                return Err(failure(
12312                    NodeFailureCode::BackendBusy,
12313                    if accepted_after.is_some() {
12314                        "history operation did not settle before the bounded deadline"
12315                    } else {
12316                        "history operation was not accepted before the bounded deadline"
12317                    },
12318                ));
12319            }
12320            sleep(Duration::from_millis(2)).await;
12321        }
12322    }
12323
12324    async fn discover_history(
12325        &self,
12326        address: &SessionAddress,
12327        limit: u16,
12328    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12329        let query = HistoryQuery {
12330            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12331            limit,
12332        };
12333        query
12334            .validate()
12335            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12336        let snapshot = self
12337            .dispatch_history_bounded(
12338                address,
12339                ControlCommand::DiscoverHistory {
12340                    instance_id: address.session.instance_id,
12341                    query: query.clone(),
12342                },
12343                HistoryOperation::Discover { query },
12344            )
12345            .await?;
12346        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12347            return Err(failure(
12348                NodeFailureCode::BackendOperationFailed,
12349                "history discovery settled into an invalid state",
12350            ));
12351        }
12352        Ok(snapshot.history.candidates)
12353    }
12354
12355    async fn load_history(
12356        &self,
12357        address: &SessionAddress,
12358        candidate_id: String,
12359    ) -> Result<HistorySessionRecord, NodeFailure> {
12360        validate_candidate_id(&candidate_id)
12361            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12362        let snapshot = self
12363            .dispatch_history_bounded(
12364                address,
12365                ControlCommand::LoadHistory {
12366                    instance_id: address.session.instance_id,
12367                    candidate_id: candidate_id.clone(),
12368                },
12369                HistoryOperation::Load {
12370                    candidate_id: candidate_id.clone(),
12371                },
12372            )
12373            .await?;
12374        if snapshot.history.pending.is_some()
12375            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12376        {
12377            return Err(failure(
12378                NodeFailureCode::BackendOperationFailed,
12379                "history load settled into an invalid state",
12380            ));
12381        }
12382        snapshot.history.loaded.ok_or_else(|| {
12383            failure(
12384                NodeFailureCode::BackendOperationFailed,
12385                "history load produced no bounded session",
12386            )
12387        })
12388    }
12389
12390    /// `allow_clean_detachment`: `false` for the on-demand
12391    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12392    /// managed record must stay untouched, still live, for the whole call);
12393    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12394    /// reacts to the very clean-exit event that detaches the record out from
12395    /// under this same call — see `session_record_export_target_matches`.
12396    async fn export_context_pack_for_session_record(
12397        &self,
12398        record_id: &SessionRecordId,
12399        session: &SessionAddress,
12400        allow_clean_detachment: bool,
12401    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12402        // C2 replaces a relayed failure's prose with a canned per-code
12403        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12404        // distinct refusals are indistinguishable to anyone downstream --
12405        // and a refusal here expires a continuation, which keeps no field
12406        // for why. The prose stays on the node, where it was written.
12407        let outcome = self
12408            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12409            .await;
12410        if let Err(failure) = &outcome {
12411            tracing::warn!(
12412                record = %record_id,
12413                code = ?failure.code,
12414                reason = %failure.message,
12415                "session-record context-pack export refused",
12416            );
12417        }
12418        outcome
12419    }
12420
12421    async fn export_context_pack_for_session_record_inner(
12422        &self,
12423        record_id: &SessionRecordId,
12424        session: &SessionAddress,
12425        allow_clean_detachment: bool,
12426    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12427        // Several steps here refuse with the same BackendOperationFailed
12428        // code and no prose of their own, so which step failed is
12429        // otherwise unrecoverable -- and a refusal here expires a
12430        // continuation.
12431        let step = |name: &'static str| move |failure: NodeFailure| {
12432            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12433            failure
12434        };
12435        let record = self.record(record_id).map_err(step("record-lookup"))?;
12436        // `provider_session` is `Some` only for a provider whose resolved
12437        // runtime policy admits live identity verification (raw PTY plus a
12438        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12439        // The PTY-sidecar-observation shape (no fleet provider today, but
12440        // capability-keyed rather than vendor-locked) hardcodes
12441        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12442        // so its records go straight to `Live` with `provider_session: None`
12443        // and stay that way for the record's entire life -- that is this
12444        // shape's permanent resting state, not a race with observation, and
12445        // `discover_history`/`load_history` below do not need an identity to
12446        // do their job for it. `IdentityPending` is the actual race (an
12447        // identity-admitted provider whose observation has not landed yet):
12448        // it keeps refusing here rather than falling back, because that
12449        // provider's own history the fallback would otherwise trust is not
12450        // guaranteed to be exact without the identity it is still waiting on.
12451        let identity = match record.provider_session.clone() {
12452            Some(identity) => {
12453                if identity.key != ProviderSessionKey::SessionId {
12454                    return Err(step("provider-identity-key")(failure(
12455                        NodeFailureCode::BackendOperationFailed,
12456                        "managed session provider identity cannot bind history exactly",
12457                    )));
12458                }
12459                Some(identity)
12460            }
12461            None if record.state == ManagedSessionState::IdentityPending => {
12462                return Err(step("provider-session-identity")(failure(
12463                    NodeFailureCode::BackendOperationFailed,
12464                    "managed session has not yet observed its provider session identity",
12465                )));
12466            }
12467            None => None,
12468        };
12469        self.revalidate_session_record_context_export(
12470            &record,
12471            identity.as_ref(),
12472            session,
12473            allow_clean_detachment,
12474        ).map_err(step("revalidate-before-discovery"))?;
12475        let candidates = self
12476            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12477            .await
12478            .map_err(step("discover-history"))?;
12479        // With no verified identity to filter by, the discovered set must
12480        // still resolve to exactly one candidate: the same ambiguity refusal
12481        // an identity filter would otherwise produce is the only safety
12482        // property left available for this shape.
12483        let candidate_id = select_history_candidate(
12484            &candidates,
12485            identity.as_ref().map(|identity| identity.id.as_str()),
12486        ).map_err(step("unique-candidate"))?;
12487        let loaded = self.load_history(session, candidate_id.clone()).await
12488            .map_err(step("load-history"))?;
12489        if let Some(identity) = identity.as_ref() {
12490            if loaded.session_id != identity.id {
12491                return Err(failure(
12492                    NodeFailureCode::SessionRecordConflict,
12493                    "loaded history identity does not match the managed session record",
12494                ));
12495            }
12496        }
12497        self.revalidate_session_record_context_export(
12498            &record,
12499            identity.as_ref(),
12500            session,
12501            allow_clean_detachment,
12502        )?;
12503        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12504            .map_err(step("revalidate-loaded-history"))?;
12505        let pack = self.materialize_context_pack(session).await
12506            .map_err(step("materialize-context-pack"))?;
12507        self.commit_context_pack_for_session_record(
12508            &record,
12509            identity.as_ref(),
12510            session,
12511            &candidate_id,
12512            &loaded,
12513            pack,
12514            allow_clean_detachment,
12515        )
12516    }
12517
12518    async fn export_context_pack(
12519        &self,
12520        address: &SessionAddress,
12521    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12522        let pack = self.materialize_context_pack(address).await?;
12523        self.commit_context_pack(pack)
12524    }
12525
12526    async fn materialize_context_pack(
12527        &self,
12528        address: &SessionAddress,
12529    ) -> Result<NodeContextPack, NodeFailure> {
12530        let snapshot = self.internal_session_snapshot(address)?;
12531        if !context_pack_source_status_is_usable(&snapshot.status) {
12532            return Err(failure(
12533                NodeFailureCode::InvalidRequest,
12534                "context export requires a running or successfully exited source session",
12535            ));
12536        }
12537        if snapshot.history.pending.is_some()
12538            || snapshot.history.loaded_candidate_id.is_none()
12539        {
12540            return Err(failure(
12541                NodeFailureCode::InvalidRequest,
12542                "context export requires settled loaded history",
12543            ));
12544        }
12545        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12546            failure(
12547                NodeFailureCode::InvalidRequest,
12548                "context export requires settled loaded history",
12549            )
12550        })?;
12551        let source_provider = snapshot.agent_id.clone();
12552        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12553        let repository = self
12554            .context_pack_repository(&address.workspace_id)
12555            .await?;
12556        let current = self.internal_session_snapshot(address)?;
12557        if !context_pack_source_status_is_usable(&current.status)
12558            || current.agent_id != source_provider
12559            || current.history.pending.is_some()
12560            || current.history.loaded_candidate_id != loaded_candidate_id
12561            || current.history.loaded.as_ref() != Some(&loaded)
12562        {
12563            return Err(failure(
12564                NodeFailureCode::ContextPackMaterializationFailed,
12565                "context source changed during bounded repository capture",
12566            ));
12567        }
12568        NodeContextPack::export_with_repository(
12569            ContextPackLineageReceipt {
12570                source_node_id: self.node_id.clone(),
12571                source_session: address.clone(),
12572                source_provider,
12573            },
12574            &loaded,
12575            Some(repository),
12576        )
12577        .map_err(|_| {
12578            failure(
12579                NodeFailureCode::ContextPackMaterializationFailed,
12580                "bounded context pack export failed",
12581            )
12582        })
12583    }
12584
12585    /// Write-through to the durable [`ContextPackStore`], if one is
12586    /// configured (`state_path` is `Some`). A node with no durable state
12587    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12588    /// open at all and this is a no-op, matching `persist_state`'s own
12589    /// "durability optional, degrade gracefully when unconfigured" idiom.
12590    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12591        let mut store = self
12592            .context_pack_store
12593            .lock()
12594            .unwrap_or_else(|poisoned| poisoned.into_inner());
12595        let Some(store) = store.as_mut() else {
12596            return Ok(());
12597        };
12598        store.commit(pack).map_err(|_| {
12599            failure(
12600                NodeFailureCode::ContextPackMaterializationFailed,
12601                "durable context pack commit failed",
12602            )
12603        })
12604    }
12605
12606    fn commit_context_pack(
12607        &self,
12608        pack: NodeContextPack,
12609    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12610        self.commit_context_pack_durably(&pack)?;
12611        self.context_catalog
12612            .write()
12613            .unwrap_or_else(|poisoned| poisoned.into_inner())
12614            .insert(pack)
12615            .map_err(|_| {
12616                failure(
12617                    NodeFailureCode::ContextPackMaterializationFailed,
12618                    "context pack catalog is full",
12619                )
12620            })
12621    }
12622
12623    fn commit_context_pack_for_session_record(
12624        &self,
12625        expected: &ManagedSessionRecord,
12626        identity: Option<&ProviderSessionIdentity>,
12627        session: &SessionAddress,
12628        candidate_id: &str,
12629        loaded: &HistorySessionRecord,
12630        pack: NodeContextPack,
12631        allow_clean_detachment: bool,
12632    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12633        let current_root = self.workspace_root(&expected.workspace_id)?;
12634        let _transaction = self
12635            .state_transaction
12636            .lock()
12637            .unwrap_or_else(|poisoned| poisoned.into_inner());
12638        let records = self
12639            .session_records
12640            .lock()
12641            .unwrap_or_else(|poisoned| poisoned.into_inner());
12642        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12643            failure(
12644                NodeFailureCode::SessionRecordConflict,
12645                "managed session record disappeared during context export",
12646            )
12647        })?;
12648        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12649            return Err(failure(
12650                NodeFailureCode::SessionRecordConflict,
12651                "managed session record changed during context export",
12652            ));
12653        }
12654        let bindings = self
12655            .session_bindings
12656            .lock()
12657            .unwrap_or_else(|poisoned| poisoned.into_inner());
12658        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12659            failure(
12660                NodeFailureCode::SessionRecordConflict,
12661                "managed session runtime binding disappeared during context export",
12662            )
12663        })?;
12664        let runtime = self
12665            .handle
12666            .snapshot()
12667            .sessions
12668            .iter()
12669            .find(|snapshot| {
12670                snapshot.instance_id == session.session.instance_id
12671                    && snapshot.generation == session.session.generation
12672            })
12673            .cloned()
12674            .ok_or_else(|| {
12675                failure(
12676                    NodeFailureCode::SessionRecordConflict,
12677                    "managed session runtime disappeared during context export",
12678                )
12679            })?;
12680        let receipt = pack.receipt();
12681        if !session_record_context_export_binding_is_exact(
12682            expected,
12683            current,
12684            identity,
12685            session,
12686            binding,
12687            &runtime.agent_id,
12688            &current_root,
12689            allow_clean_detachment,
12690        ) || !session_record_context_export_source_is_usable(
12691            current,
12692            &runtime.status,
12693            allow_clean_detachment,
12694        ) || runtime.history.pending.is_some()
12695            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12696            || runtime.history.loaded.as_ref() != Some(loaded)
12697            || receipt.lineage.source_node_id != self.node_id
12698            || receipt.lineage.source_session != *session
12699            || receipt.lineage.source_provider != current.provider
12700            || receipt.source_message_count != loaded.message_count
12701        {
12702            return Err(failure(
12703                NodeFailureCode::SessionRecordConflict,
12704                "managed session source changed before context catalog commit",
12705            ));
12706        }
12707        self.commit_context_pack_durably(&pack)?;
12708        self.context_catalog
12709            .write()
12710            .unwrap_or_else(|poisoned| poisoned.into_inner())
12711            .insert(pack)
12712            .map_err(|_| {
12713                failure(
12714                    NodeFailureCode::ContextPackMaterializationFailed,
12715                    "context pack catalog is full",
12716                )
12717            })
12718    }
12719
12720    async fn context_pack_repository(
12721        &self,
12722        workspace_id: &WorkspaceId,
12723    ) -> Result<ContextPackRepository, NodeFailure> {
12724        let first = self
12725            .context_pack_repository_observation(workspace_id)
12726            .await?;
12727        let second = self
12728            .context_pack_repository_observation(workspace_id)
12729            .await?;
12730        stable_context_pack_repository(first, second)
12731    }
12732
12733    async fn context_pack_repository_observation(
12734        &self,
12735        workspace_id: &WorkspaceId,
12736    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12737        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12738        let head = if git.is_repository {
12739            let root = self.workspace_root(workspace_id)?;
12740            match resolve_base_commit_with_timeout(
12741                &root,
12742                "HEAD",
12743                GIT_COMMAND_TIMEOUT_MS,
12744            )
12745            .await
12746            {
12747                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12748                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12749                    ContextPackRepositoryHead::Unborn
12750                }
12751                Err(_) => {
12752                    return Err(failure(
12753                        NodeFailureCode::ContextPackMaterializationFailed,
12754                        "context pack HEAD observation failed",
12755                    ));
12756                }
12757            }
12758        } else {
12759            ContextPackRepositoryHead::NotRepository
12760        };
12761        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12762        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12763            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12764                failure(
12765                    NodeFailureCode::InvalidRepositoryPath,
12766                    "context pack selected file path is invalid",
12767                )
12768            })?;
12769            match self.read_workspace_file(workspace_id.clone(), path).await {
12770                Ok(WorkspaceFileRead {
12771                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12772                    ..
12773                }) => files.push(ContextPackRepositoryFileSource::utf8(
12774                    selected_path,
12775                    text,
12776                    byte_len,
12777                )),
12778                Ok(WorkspaceFileRead {
12779                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12780                    ..
12781                }) => files.push(ContextPackRepositoryFileSource::skipped(
12782                    selected_path,
12783                    ContextPackSelectedFileSkipReason::NonUtf8,
12784                    Some(byte_len),
12785                )),
12786                Ok(WorkspaceFileRead {
12787                    content: WorkspaceFileContent::TooLarge { .. },
12788                    ..
12789                }) => files.push(ContextPackRepositoryFileSource::skipped(
12790                    selected_path,
12791                    ContextPackSelectedFileSkipReason::TooLarge,
12792                    None,
12793                )),
12794                Err(error) => {
12795                    let reason = if matches!(
12796                        error.code,
12797                        NodeFailureCode::InvalidRepositoryPath
12798                            | NodeFailureCode::RepositoryFileNotRegular
12799                            | NodeFailureCode::RepositoryPathUnsafe
12800                    ) {
12801                        ContextPackSelectedFileSkipReason::Unsafe
12802                    } else {
12803                        ContextPackSelectedFileSkipReason::Unavailable
12804                    };
12805                    files.push(ContextPackRepositoryFileSource::skipped(
12806                        selected_path,
12807                        reason,
12808                        None,
12809                    ));
12810                }
12811            }
12812        }
12813        Ok(ContextPackRepositoryObservation { head, git, files })
12814    }
12815
12816    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12817        let referenced_by_binding = self
12818            .session_bindings
12819            .lock()
12820            .unwrap_or_else(|poisoned| poisoned.into_inner())
12821            .values()
12822            .any(|binding| {
12823                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12824            });
12825        let referenced_by_record = self
12826            .session_records
12827            .lock()
12828            .unwrap_or_else(|poisoned| poisoned.into_inner())
12829            .records
12830            .values()
12831            .any(|record| record.context_id.as_ref() == Some(context_id));
12832        let referenced_by_materialization = self
12833            .materializations
12834            .lock()
12835            .unwrap_or_else(|poisoned| poisoned.into_inner())
12836            .values()
12837            .any(|ownership| {
12838                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12839            });
12840        if referenced_by_binding
12841            || referenced_by_record
12842            || referenced_by_materialization
12843        {
12844            return Err(failure(
12845                NodeFailureCode::ContextPackBusy,
12846                "context pack is still referenced",
12847            ));
12848        }
12849        let mut catalog = self.context_catalog
12850            .write()
12851            .unwrap_or_else(|poisoned| poisoned.into_inner());
12852        if catalog.get(context_id).is_none() {
12853            return Err(failure(
12854                NodeFailureCode::UnknownContextPack,
12855                "context pack is unavailable",
12856            ));
12857        }
12858        self.spawn_idempotency
12859            .lock()
12860            .unwrap_or_else(|poisoned| poisoned.into_inner())
12861            .entries
12862            .retain(|_, entry| !entry.value.references_context(context_id));
12863        let removed = catalog.remove(context_id);
12864        debug_assert!(removed.is_some(), "validated context pack remains present");
12865        Ok(())
12866    }
12867
12868    async fn dispatch_input_bounded(
12869        &self,
12870        address: &SessionAddress,
12871        action: InputAction,
12872    ) -> Result<(), NodeFailure> {
12873        let started_after = self.current_sequence();
12874        self.pty_input_marks
12875            .lock()
12876            .unwrap_or_else(|poisoned| poisoned.into_inner())
12877            .insert(address.session.instance_id, (Instant::now(), false));
12878        self.pty_input_wake.notify_one();
12879        let command_id = self
12880            .dispatch_bounded(
12881                ControlCommand::SendInput {
12882                    instance_id: address.session.instance_id,
12883                    action,
12884                },
12885                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12886            )
12887            .await?;
12888        // A semantic prompt may spend the provider's full readiness budget before
12889        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12890        // but strictly beyond every readiness policy in this node's catalog.
12891        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12892        let mut scan_after = started_after;
12893        let mut accepted_after = None;
12894        loop {
12895            let events = self
12896                .history
12897                .lock()
12898                .unwrap_or_else(|poisoned| poisoned.into_inner())
12899                .events
12900                .iter()
12901                .filter(|envelope| envelope.sequence > scan_after)
12902                .cloned()
12903                .collect::<Vec<_>>();
12904            for envelope in events {
12905                scan_after = scan_after.max(envelope.sequence);
12906                let NodeEvent::Control {
12907                    address: event_address,
12908                    event,
12909                } = envelope.event
12910                else {
12911                    continue;
12912                };
12913                if event_address != *address {
12914                    continue;
12915                }
12916                if event.command_id == Some(command_id) {
12917                    match &event.event {
12918                        ControlEventKind::CommandRejected { message } => {
12919                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12920                        }
12921                        ControlEventKind::InputRequested { .. } => {
12922                            accepted_after = Some(envelope.sequence);
12923                            continue;
12924                        }
12925                        _ => {}
12926                    }
12927                }
12928                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12929                    match &event.event {
12930                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12931                        ControlEventKind::InputFailed { message, .. } => {
12932                            return Err(failure(
12933                                NodeFailureCode::BackendOperationFailed,
12934                                message,
12935                            ));
12936                        }
12937                        _ => {}
12938                    }
12939                }
12940            }
12941            if Instant::now() >= deadline {
12942                return Err(failure(
12943                    NodeFailureCode::BackendBusy,
12944                    if accepted_after.is_some() {
12945                        "input was accepted but did not settle before the bounded deadline"
12946                    } else {
12947                        "input was not accepted before the bounded deadline"
12948                    },
12949                ));
12950            }
12951            sleep(Duration::from_millis(2)).await;
12952        }
12953    }
12954
12955    async fn dispatch_resize_bounded(
12956        &self,
12957        address: &SessionAddress,
12958        size: gate4agent_types::TerminalSize,
12959    ) -> Result<(), NodeFailure> {
12960        let started_after = self.current_sequence();
12961        let command_id = self
12962            .dispatch_bounded(
12963                ControlCommand::Resize {
12964                    instance_id: address.session.instance_id,
12965                    size,
12966                },
12967                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12968            )
12969            .await?;
12970        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12971        let mut scan_after = started_after;
12972        let mut accepted_after = None;
12973        loop {
12974            let events = self
12975                .history
12976                .lock()
12977                .unwrap_or_else(|poisoned| poisoned.into_inner())
12978                .events
12979                .iter()
12980                .filter(|envelope| envelope.sequence > scan_after)
12981                .cloned()
12982                .collect::<Vec<_>>();
12983            for envelope in events {
12984                scan_after = scan_after.max(envelope.sequence);
12985                let NodeEvent::Control {
12986                    address: event_address,
12987                    event,
12988                } = envelope.event
12989                else {
12990                    continue;
12991                };
12992                if event_address != *address {
12993                    continue;
12994                }
12995                if event.command_id == Some(command_id) {
12996                    match &event.event {
12997                        ControlEventKind::CommandRejected { message } => {
12998                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12999                        }
13000                        ControlEventKind::ResizeRequested {
13001                            size: requested_size,
13002                            ..
13003                        } if *requested_size == size => {
13004                            accepted_after = Some(envelope.sequence);
13005                            continue;
13006                        }
13007                        _ => {}
13008                    }
13009                }
13010                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13011                    match &event.event {
13012                        ControlEventKind::Resized { size: settled_size }
13013                            if *settled_size == size =>
13014                        {
13015                            return Ok(());
13016                        }
13017                        ControlEventKind::ResizeFailed { message } => {
13018                            return Err(failure(
13019                                NodeFailureCode::BackendOperationFailed,
13020                                message,
13021                            ));
13022                        }
13023                        _ => {}
13024                    }
13025                }
13026            }
13027            if Instant::now() >= deadline {
13028                return Err(failure(
13029                    NodeFailureCode::BackendBusy,
13030                    if accepted_after.is_some() {
13031                        "resize was accepted but did not settle before the bounded deadline"
13032                    } else {
13033                        "resize was not accepted before the bounded deadline"
13034                    },
13035                ));
13036            }
13037            sleep(Duration::from_millis(2)).await;
13038        }
13039    }
13040
13041    async fn spawn_session(
13042        &self,
13043        workspace_id: WorkspaceId,
13044        provider: AgentId,
13045        mode: SessionMode,
13046        terminal_size: gate4agent_types::TerminalSize,
13047        initial_prompt: Option<String>,
13048    ) -> Result<SessionAddress, NodeFailure> {
13049        if self.managed_worktrees.lock()
13050            .unwrap_or_else(|poisoned| poisoned.into_inner())
13051            .lease_for_workspace(&workspace_id)
13052            .is_some()
13053        {
13054            return Err(failure(
13055                NodeFailureCode::ManagedWorktreeOwnershipConflict,
13056                "managed worktree targets may only be spawned through their lease request",
13057            ));
13058        }
13059        self
13060            .spawn_session_with_deadline(
13061                workspace_id,
13062                provider.clone(),
13063                mode,
13064                terminal_size,
13065                initial_prompt,
13066                None,
13067                None,
13068                None,
13069                None,
13070                None,
13071                SpawnRecordPolicy::ProviderIdentityOnly,
13072                None,
13073                &[],
13074                None,
13075                // `NodeRequest::Spawn` (this method's only caller) carries no
13076                // approval-level selection of its own -- see
13077                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13078                // the one operator-wire path that can choose something other
13079                // than the axis default. Auto-permit may lower it where the agent
13080                // cannot offer its full-auto mode (claude ACP under root).
13081                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13082                None,
13083                None,
13084            )
13085            .await
13086            .map(|(session, _runtime_policy)| session)
13087    }
13088
13089    async fn spawn_session_with_deadline(
13090        &self,
13091        workspace_id: WorkspaceId,
13092        provider: AgentId,
13093        mode: SessionMode,
13094        terminal_size: gate4agent_types::TerminalSize,
13095        initial_prompt: Option<String>,
13096        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13097        bundle: Option<ResolvedBundleReceipt>,
13098        context: Option<ResolvedContextPackReceipt>,
13099        managed_authority: Option<ManagedWorktreeLeaseId>,
13100        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13101        record_policy: SpawnRecordPolicy,
13102        deadline: Option<Instant>,
13103        required_capabilities: &[ProviderRuntimeCapability],
13104        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13105        approval_level: ApprovalLevel,
13106        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13107        // Opaque dig2browser station profile id (ids only). Feature
13108        // `dig2-station-probe`: after probe at resolve, spawn takes an
13109        // exclusive node-local lease; cleared on session end. Never
13110        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13111        // already happened on the receipt path).
13112        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13113    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13114        let reserved_lease = self.managed_worktrees.lock()
13115            .unwrap_or_else(|poisoned| poisoned.into_inner())
13116            .lease_for_workspace(&workspace_id);
13117        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13118            (Some(expected), Some(provided)) if expected == provided => {}
13119            (None, None) => {}
13120            _ => {
13121                return Err(failure(
13122                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13123                    "spawn lacks exact managed worktree lease authority",
13124                ));
13125            }
13126        }
13127        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13128        // bindings) on the same workspace cwd. Parallel path: managed worktree
13129        // spawn so each profile gets its own workspace_id.
13130        self.ensure_workspace_environment_profile_compatible(
13131            &workspace_id,
13132            environment_profile.as_ref(),
13133        )?;
13134        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13135            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13136            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13137            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13138            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13139        };
13140        // This session's own transport -- computed once, here, from `mode`,
13141        // and reused both to admit spawn (below) and, further down, to
13142        // compute the final `runtime_policy` this session actually operates
13143        // under (`provider_runtime::policy_for_transport`). Registration
13144        // reuses this exact value rather than recomputing it a second time.
13145        let transport = match mode {
13146            SessionMode::Pty => TransportKind::Pty,
13147            SessionMode::Inline => TransportKind::Pipe,
13148            SessionMode::Acp => TransportKind::Acp,
13149        };
13150        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13151        let acp_transport = self.provider_supports_acp_transport(&provider);
13152        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13153            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13154                NodeFailureCode::UnsupportedSpawnCapability,
13155                "pre-admitted provider runtime no longer satisfies spawn requirements",
13156            ))?;
13157            runtime_policy
13158        } else if let Some(deadline) = deadline {
13159            let remaining = spawn_deadline_remaining(deadline)?;
13160            timeout(
13161                remaining,
13162                self.admit_provider_runtime(&provider, runtime_requirement),
13163            )
13164            .await
13165            .map_err(|_| {
13166                failure(
13167                    NodeFailureCode::SpawnDeadlineExceeded,
13168                    "provider admission exceeded the spawn deadline",
13169                )
13170            })??
13171        } else {
13172            self.admit_provider_runtime(&provider, runtime_requirement).await?
13173        };
13174        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13175        // The single point where this session's `runtime_policy` becomes
13176        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13177        // branch above) only ever compute a PTY-terminal-verification-shaped
13178        // value, sound outright only for `TransportKind::Pty` (Pipe still
13179        // relies on that same value downstream -- see `policy_for_transport`'s
13180        // own doc for why). `policy_for_transport` is the explicit,
13181        // exhaustive match Item 1 asks for -- this is the ONE place its
13182        // output becomes the session's `runtime_policy`, so every later
13183        // reader (`ControlCommand::Start`, `session_bindings`, and every
13184        // engine gate that reads `state.runtime_policy`) sees the same,
13185        // correctly-computed value.
13186        let runtime_policy =
13187            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13188        if required_capabilities
13189            .iter()
13190            .any(|capability| !runtime_policy.admits(*capability))
13191        {
13192            return Err(failure(
13193                NodeFailureCode::UnsupportedSpawnCapability,
13194                "provider runtime does not admit a required spawn capability",
13195            ));
13196        }
13197        if let Some(deadline) = deadline {
13198            spawn_deadline_remaining(deadline)?;
13199        }
13200        self.ensure_binding_capacity()?;
13201        let mut working_directory = self.workspace_root(&workspace_id)?;
13202        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13203        let session = SessionKey {
13204            instance_id,
13205            generation: SessionGeneration(1),
13206        };
13207        let address = SessionAddress {
13208            workspace_id: workspace_id.clone(),
13209            session,
13210        };
13211        // This is the request-accepted checkpoint: admission (managed
13212        // worktree authority, runtime capability, dispatch deadline) already
13213        // passed and a concrete session identity is minted. It is a distinct
13214        // state from the process actually running (`ControlEventKind::Running`,
13215        // logged in `publish_control`) or the terminal outcome (`Exited`/
13216        // `Failed`, logged there too) -- this line exists so that gap is no
13217        // longer silent.
13218        tracing::info!(
13219            node_id = %self.node_id,
13220            workspace_id = %workspace_id,
13221            provider = %provider,
13222            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13223            session = ?address.session,
13224            "session spawn request accepted",
13225        );
13226        let mut prepared_spawn = SpawnPreparer::prepare(
13227            self,
13228            SpawnPrepareRequest {
13229                instance_id,
13230                address: &address,
13231                provider: &provider,
13232                mode,
13233                environment_profile: environment_profile.as_ref(),
13234                bundle: bundle.as_ref(),
13235                context: context.as_ref(),
13236                managed_authority: managed_authority.as_ref(),
13237                harness_mcp,
13238                approval_level,
13239                network_allowlist: network_allowlist.as_ref(),
13240                browser_profile_id,
13241            },
13242        )?;
13243        let materialization_id = prepared_spawn.materialization_id.take();
13244        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13245        let record_id = self.bind_spawn_session_with_materialization(
13246            &address,
13247            provider.clone(),
13248            mode,
13249            runtime_policy,
13250            record_policy,
13251            environment_profile,
13252            bundle,
13253            context,
13254            materialization_id,
13255        )?;
13256        prepared_spawn.retain_session_guards();
13257        // `transport` was already computed above, alongside this session's
13258        // `runtime_policy`; reused here for `Register` rather than derived a
13259        // second time.
13260        let session_provider = provider.clone();
13261        let agent_id = provider;
13262        let register_command_id = match self.dispatch_bounded(
13263            ControlCommand::Register {
13264                instance_id,
13265                agent_id,
13266                transport,
13267            },
13268            dispatch_timeout,
13269        )
13270        .await {
13271            Ok(command_id) => command_id,
13272            Err(error) => {
13273                if let Some(binding) = self.remove_binding(&address) {
13274                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13275                }
13276                if let Some(record_id) = record_id.as_ref() {
13277                    self.discard_record(record_id)?;
13278                }
13279                return Err(spawn_dispatch_error(error, deadline));
13280            }
13281        };
13282        let start_timeout = match spawn_dispatch_timeout(deadline) {
13283            Ok(timeout) => timeout,
13284            Err(error) => {
13285                let recovery = self
13286                    .rollback_spawn(
13287                        &address,
13288                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13289                    )
13290                    .await;
13291                return match recovery {
13292                    Ok(()) => {
13293                        if let Some(record_id) = record_id.as_ref() {
13294                            self.discard_record(record_id)?;
13295                        }
13296                        Err(error)
13297                    }
13298                    Err(recovery_error) => {
13299                        if let Some(record_id) = record_id.as_ref() {
13300                            self.mark_record_error(
13301                                record_id,
13302                                "spawn deadline elapsed and registration recovery failed",
13303                            )?;
13304                        }
13305                        Err(recovery_error)
13306                    }
13307                };
13308            }
13309        };
13310        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13311            if let Err(recovery_error) = self
13312                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13313                .await
13314            {
13315                return Err(recovery_error);
13316            }
13317            if let Some(record_id) = record_id.as_ref() {
13318                self.discard_record(record_id)?;
13319            }
13320            return Err(start_error);
13321        }
13322        // A session with its own m4a account runs in its own folder inside the workspace.
13323        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13324            working_directory = dir.to_string_lossy().into_owned();
13325        }
13326        let start_result = self
13327            .dispatch_bounded(
13328                ControlCommand::Start {
13329                    instance_id,
13330                    runtime_policy,
13331                    request: StartRequest {
13332                        working_directory,
13333                        terminal_size,
13334                        initial_prompt,
13335                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13336                        approval_level,
13337                    },
13338                },
13339                start_timeout,
13340            )
13341            .await;
13342        let start_command_id = match start_result {
13343            Ok(command_id) => command_id,
13344            Err(start_error) => {
13345                let start_error = spawn_dispatch_error(start_error, deadline);
13346                let recovery = self
13347                    .rollback_spawn(
13348                        &address,
13349                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13350                    )
13351                    .await;
13352                return match recovery {
13353                    Ok(()) => {
13354                        if let Some(record_id) = record_id.as_ref() {
13355                            self.discard_record(record_id)?;
13356                        }
13357                        Err(start_error)
13358                    }
13359                    Err(recovery_error) => {
13360                        let diagnostic = format!(
13361                            "start failed and registration recovery failed: {}",
13362                            recovery_error.message,
13363                        );
13364                        if let Some(record_id) = record_id.as_ref() {
13365                            self.mark_record_error(record_id, &diagnostic)?;
13366                        }
13367                        Err(failure(recovery_error.code, &diagnostic))
13368                    }
13369                };
13370            }
13371        };
13372
13373        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13374        loop {
13375            // `Register`/`Start` only fail synchronously for a port-level
13376            // ingress problem (see `dispatch_bounded`); the kernel refusing
13377            // either command's content -- an unsupported transport, an
13378            // unknown agent -- lands one drive-loop tick later, and no
13379            // session ever appears in `self.handle.snapshot()` for it to
13380            // notice below. Check for that named reason before falling
13381            // through to the blind presence poll, so a rejected spawn
13382            // reports why instead of just timing out.
13383            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13384                .or_else(|| self.take_rejected_command_reason(start_command_id))
13385            {
13386                self.rollback_spawn(
13387                    &address,
13388                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13389                ).await.map_err(|error| {
13390                        failure(
13391                            error.code,
13392                            &format!(
13393                                "spawn was rejected ({}) and registration recovery failed: {}",
13394                                reason.message, error.message
13395                            ),
13396                        )
13397                    })?;
13398                if let Some(record_id) = record_id.as_ref() {
13399                    self.discard_record(record_id)?;
13400                }
13401                let code = if reason.unsupported_transport {
13402                    NodeFailureCode::UnsupportedTransport
13403                } else {
13404                    NodeFailureCode::BackendOperationFailed
13405                };
13406                return Err(failure(code, &reason.message));
13407            }
13408            if let Some(current) = self
13409                .handle
13410                .snapshot()
13411                .sessions
13412                .iter()
13413                .find(|current| current.instance_id == instance_id)
13414            {
13415                if current.generation != session.generation {
13416                    let divergence = failure(
13417                        NodeFailureCode::StaleGeneration,
13418                        "spawn generation diverged",
13419                    );
13420                    return match self
13421                        .rollback_spawn(
13422                            &address,
13423                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13424                        )
13425                        .await
13426                    {
13427                        Ok(()) => {
13428                            if let Some(record_id) = record_id.as_ref() {
13429                                self.discard_record(record_id)?;
13430                            }
13431                            Err(divergence)
13432                        }
13433                        Err(recovery_error) => {
13434                            if let Some(record_id) = record_id.as_ref() {
13435                                self.mark_record_error(
13436                                    record_id,
13437                                    "spawn generation diverged and recovery failed",
13438                                )?;
13439                            }
13440                            Err(recovery_error)
13441                        }
13442                    };
13443                }
13444                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13445                    prepared_spawn.retain_launch_guards();
13446                    return Ok((address, runtime_policy));
13447                }
13448            }
13449            if Instant::now() >= commit_deadline {
13450                self.rollback_spawn(
13451                    &address,
13452                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13453                ).await.map_err(|error| {
13454                        failure(
13455                            error.code,
13456                            &format!(
13457                                "start did not commit and registration recovery failed: {}",
13458                                error.message
13459                            ),
13460                        )
13461                    })?;
13462                if let Some(record_id) = record_id.as_ref() {
13463                    self.discard_record(record_id)?;
13464                }
13465                return Err(failure(
13466                    if deadline.is_some() {
13467                        NodeFailureCode::SpawnDeadlineExceeded
13468                    } else {
13469                        NodeFailureCode::BackendBusy
13470                    },
13471                    "start did not commit before the bounded deadline; registration was removed",
13472                ));
13473            }
13474            sleep(Duration::from_millis(2)).await;
13475        }
13476    }
13477
13478    fn effective_spawn_runtime_policy(
13479        &self,
13480        runtime_policy: ProviderRuntimePolicy,
13481    ) -> ProviderRuntimePolicy {
13482        #[cfg(feature = "fixture")]
13483        let runtime_policy = if self.fixture_semantic_hook_policy {
13484            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13485                .expect("monitoring Hook fixture policy is internally valid")
13486        } else {
13487            runtime_policy
13488        };
13489        runtime_policy
13490    }
13491
13492    fn verify_harness_mcp_before_start(
13493        &self,
13494        prepared: Option<&PreparedHarnessMcpSpawn>,
13495    ) -> Result<(), NodeFailure> {
13496        prepared
13497            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13498            .transpose()
13499            .map(|_| ())
13500            .map_err(harness_mcp_failure)
13501    }
13502
13503    async fn rollback_spawn(
13504        &self,
13505        address: &SessionAddress,
13506        commit_timeout: Duration,
13507    ) -> Result<(), NodeFailure> {
13508        self.dispatch_bounded(
13509            ControlCommand::Remove {
13510                instance_id: address.session.instance_id,
13511            },
13512            commit_timeout,
13513        )
13514        .await?;
13515        self.wait_until_removed(address, commit_timeout).await?;
13516        if let Some(binding) = self.remove_binding(address) {
13517            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13518        }
13519        Ok(())
13520    }
13521
13522    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13523        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13524        self.dispatch_bounded(
13525            ControlCommand::Remove {
13526                instance_id: address.session.instance_id,
13527            },
13528            commit_timeout,
13529        )
13530            .await?;
13531        self.wait_until_removed(address, commit_timeout).await?;
13532        let removed = self.remove_binding(address);
13533        if let Some(binding) = removed.as_ref() {
13534            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13535        }
13536        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13537            let snapshot = self.managed_worktrees.lock()
13538                .unwrap_or_else(|poisoned| poisoned.into_inner())
13539                .release_session(
13540                    &lease_id,
13541                    self.incarnation_id,
13542                    address.session.instance_id,
13543                    address.session.generation,
13544                    unix_time_ms(),
13545                );
13546            if self.persist_state().is_err() {
13547                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13548            }
13549            if let Some(snapshot) = snapshot {
13550                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13551            }
13552            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13553        }
13554        Ok(())
13555    }
13556
13557    /// Polls until `address`'s session leaves the engine's own session map,
13558    /// or the bounded deadline expires.
13559    ///
13560    /// A `Remove` sent while the session is not yet in a terminal status
13561    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13562    /// once by the engine (`SessionStatus::allows_remove`) and
13563    /// then discarded -- nothing re-issues it on its own, so the removal
13564    /// that would satisfy this wait never arrives and the caller was
13565    /// guaranteed to time out even though the session settles microseconds
13566    /// later. So this re-dispatches on every poll tick: a session that
13567    /// settles mid-wait -- the common case right after a `Stop` -- still
13568    /// gets removed inside the same bounded deadline.
13569    ///
13570    /// It re-dispatches only once the status will ACCEPT the command. A
13571    /// rejected re-dispatch is not the harmless no-op this loop first
13572    /// assumed: every rejection publishes a `ControlEventKind::Command
13573    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13574    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13575    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13576    /// out to the harness and every attached client. Gated, the same
13577    /// teardown sends exactly one `Remove`, at the first tick the engine
13578    /// can act on it.
13579    ///
13580    /// The status is read from the snapshot this loop already takes, so
13581    /// the gate costs nothing extra. It races -- the status can move
13582    /// between the read and the dispatch -- and that is fine: a rare lost
13583    /// race is one rejection, and the next tick retries.
13584    ///
13585    /// Re-dispatch is separately rate-limited to
13586    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13587    /// leaves a second burst behind it. Once the session settles, every
13588    /// 2ms tick sent another `Remove` while the accepted one was still in
13589    /// flight, and each of those landed on an instance the engine had
13590    /// already dropped -- `agent instance … is not registered`, observed
13591    /// live as a tail of rejections immediately after the `session
13592    /// removed` line. The presence poll stays at 2ms so the wait still
13593    /// exits promptly; only the sending slows down.
13594    ///
13595    /// Rate-limited rather than once-only on purpose: the status gate
13596    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13597    /// others (a pending operation, an in-flight capability probe or
13598    /// history request) that clear on their own. A single shot would turn
13599    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13600    /// get their retries, just twenty of them across the deadline instead
13601    /// of a thousand.
13602    async fn wait_until_removed(
13603        &self,
13604        address: &SessionAddress,
13605        commit_timeout: Duration,
13606    ) -> Result<(), NodeFailure> {
13607        let deadline = Instant::now() + commit_timeout;
13608        let mut next_dispatch = Instant::now();
13609        loop {
13610            let snapshot = self.handle.snapshot();
13611            let status = snapshot
13612                .sessions
13613                .iter()
13614                .find(|session| session.instance_id == address.session.instance_id)
13615                .map(|session| session.status.clone());
13616            let Some(status) = status else {
13617                return Ok(());
13618            };
13619            let now = Instant::now();
13620            if now >= deadline {
13621                return Err(failure(
13622                    NodeFailureCode::BackendBusy,
13623                    "remove did not commit before the bounded deadline; session binding was retained",
13624                ));
13625            }
13626            if status.allows_remove() && now >= next_dispatch {
13627                let _ = self.dispatch(ControlCommand::Remove {
13628                    instance_id: address.session.instance_id,
13629                });
13630                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13631            }
13632            sleep(Duration::from_millis(2)).await;
13633        }
13634    }
13635
13636    fn resync(&self, after_sequence: u64) -> NodeResponse {
13637        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13638        let event_sequence = history.last_sequence;
13639        let oldest_available_sequence = history.replay_floor_sequence;
13640        let events = history
13641            .events
13642            .iter()
13643            .filter(|event| event.sequence > after_sequence)
13644            .cloned()
13645            .collect();
13646        drop(history);
13647        NodeResponse::Resync {
13648            event_sequence,
13649            oldest_available_sequence,
13650            snapshot: self.snapshot(),
13651            events,
13652        }
13653    }
13654}
13655
13656/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13657/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13658/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13659/// "name what crossed this boundary" reasoning, one layer upstream of it).
13660fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13661    match kind {
13662        AgentStreamChunkKindV1::Text { .. } => "text",
13663        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13664        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13665        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13666        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13667        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13668        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13669    }
13670}
13671
13672/// Serves this wire on the local endpoint, and -- when `call_home` names a
13673/// relay -- over an outbound connection to it as well.
13674///
13675/// The two live in one future rather than a fourth arm of `run`'s own
13676/// `select!` because they are one concern: this is where the node's wire
13677/// is served, and the direction a given connection was opened in does not
13678/// change what happens on it. It also means the existing graceful drain
13679/// below covers both without being duplicated.
13680///
13681/// Either side finishing ends the pair, which is correct: the listener
13682/// only returns on shutdown or a bind failure, and `call_home_loop` only
13683/// returns on shutdown -- a failed relay connection is retried inside it,
13684/// never propagated out.
13685async fn accept_connections(
13686    endpoint: &str,
13687    call_home: Option<std::net::SocketAddr>,
13688    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13689    shared: Arc<NodeShared>,
13690) -> Result<(), NodeServerError> {
13691    let mut connections = JoinSet::new();
13692    let result = match call_home {
13693        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13694        Some(relay) => {
13695            let listener =
13696                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13697            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13698            tokio::pin!(listener);
13699            tokio::pin!(dialler);
13700            tokio::select! {
13701                result = &mut listener => result,
13702                result = &mut dialler => result,
13703            }
13704        }
13705    };
13706    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13707    while !connections.is_empty() {
13708        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13709        if remaining.is_zero()
13710            || timeout(remaining, connections.join_next()).await.is_err()
13711        {
13712            break;
13713        }
13714    }
13715    connections.abort_all();
13716    while connections.join_next().await.is_some() {}
13717    result
13718}
13719
13720/// Backoff ladder for a relay that will not answer, capped rather than
13721/// unbounded: a node whose relay is down is not broken and should keep
13722/// trying forever, but it should not spend a core doing it.
13723const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13724
13725/// Dials the relay, serves this wire over the connection, and does it
13726/// again when the connection ends.
13727///
13728/// The node is the SERVER on the socket it just opened. That inversion is
13729/// the whole point: `serve_connection` is the same function the local
13730/// listener feeds, and it neither knows nor cares which end placed the
13731/// call. The one thing this direction adds is the preface -- the relay
13732/// accepted a socket and cannot know whose it is, so the node says its
13733/// name before the handshake it would otherwise begin with.
13734///
13735/// Never returns an error. A relay that is down, refusing, or speaking a
13736/// different protocol is a condition to retry, not a reason to take the
13737/// node's local endpoint down with it; the only way out is shutdown.
13738async fn call_home_loop(
13739    relay: std::net::SocketAddr,
13740    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13741    shared: Arc<NodeShared>,
13742) -> Result<(), NodeServerError> {
13743    let mut failures = 0_usize;
13744    loop {
13745        if shared.shutdown.load(Ordering::Acquire) {
13746            return Ok(());
13747        }
13748        let permit = tokio::select! {
13749            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13750                permit.map_err(|_| NodeServerError::PreauthClosed)?
13751            }
13752            _ = shared.shutdown_notify.notified() => return Ok(()),
13753        };
13754        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13755            Ok(()) => {
13756                // A clean end is the relay hanging up, which it does on
13757                // its own restart. Reconnect immediately rather than
13758                // serving the backoff a peer that is plainly there.
13759                failures = 0;
13760                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13761            }
13762            Err(error) => {
13763                failures = failures.saturating_add(1);
13764                tracing::warn!(
13765                    node_id = %shared.node_id,
13766                    %relay,
13767                    cause = %error,
13768                    attempt = failures,
13769                    "call-home connection failed",
13770                );
13771                let backoff = call_home_backoff(failures);
13772                tokio::select! {
13773                    _ = sleep(backoff) => {}
13774                    _ = shared.shutdown_notify.notified() => return Ok(()),
13775                }
13776            }
13777        }
13778    }
13779}
13780
13781/// Idle time before the first keepalive probe on a call-home socket.
13782const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13783/// Interval between keepalive probes once the link has gone quiet.
13784const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13785
13786fn call_home_keepalive() -> socket2::TcpKeepalive {
13787    let keepalive = socket2::TcpKeepalive::new()
13788        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13789        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13790    #[cfg(not(windows))]
13791    let keepalive = keepalive.with_retries(3);
13792    keepalive
13793}
13794
13795fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13796    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13797}
13798
13799/// Same keepalive profile for any node->relay socket (restream uplink).
13800pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13801    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13802}
13803
13804/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13805fn call_home_backoff(attempt: usize) -> Duration {
13806    Duration::from_millis(
13807        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13808    )
13809}
13810
13811
13812/// Dial the call-home relay. Tunnel addresses go through the in-process
13813/// WireGuard dialer when the userspace session is up.
13814async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13815    #[cfg(feature = "wireguard")]
13816    {
13817        let std_stream = tokio::task::spawn_blocking(move || {
13818            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13819        })
13820        .await
13821        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13822        .map_err(NodeServerError::CallHomeTransport)?;
13823        std_stream
13824            .set_nonblocking(true)
13825            .map_err(NodeServerError::CallHomeTransport)?;
13826        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13827    }
13828    #[cfg(not(feature = "wireguard"))]
13829    {
13830        tokio::net::TcpStream::connect(relay)
13831            .await
13832            .map_err(NodeServerError::CallHomeTransport)
13833    }
13834}
13835
13836/// One call-home attempt: connect, name this node, then serve.
13837async fn dial_relay_and_serve(
13838    relay: std::net::SocketAddr,
13839    identity: Option<&hatchery_enroll::NodeIdentity>,
13840    shared: Arc<NodeShared>,
13841    preauth_permit: OwnedSemaphorePermit,
13842) -> Result<(), NodeServerError> {
13843    let mut stream = dial_call_home(relay).await?;
13844    // Nagle off for the same reason the rest of this wire wants it off:
13845    // the traffic is small request/response frames, and coalescing them
13846    // adds latency to every one.
13847    let _ = stream.set_nodelay(true);
13848    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13849    // relay-side socket kill whose RST was lost) would otherwise leave this
13850    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13851    // fails and the reconnect loop never runs. Kernel keepalive turns that
13852    // half-open link into a read error within a bounded time.
13853    if let Err(error) = enable_call_home_keepalive(&stream) {
13854        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13855    }
13856    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13857        .await
13858        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13859    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13860    let mut session_token = None;
13861    if let Some(identity) = identity {
13862        // An enrolled relay answers the announce with a signed-call-home challenge; a
13863        // token-mode relay sends its wire hello instead. Peek to tell them apart
13864        // without consuming either.
13865        let mut magic = [0_u8; 4];
13866        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13867            .await
13868            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13869            .map_err(NodeServerError::CallHomeTransport)?;
13870        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13871            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13872            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13873                .await
13874                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13875                .map_err(NodeServerError::CallHomeTransport)?;
13876            let (answer, token) = identity
13877                .answer_challenge(shared.node_id.as_str(), &frame)
13878                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13879            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13880            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13881            session_token = Some(token);
13882        }
13883    }
13884    serve_connection(stream, shared, session_token, preauth_permit).await
13885}
13886
13887async fn accept_connections_inner(
13888    endpoint: &str,
13889    shared: Arc<NodeShared>,
13890    connections: &mut JoinSet<Result<(), NodeServerError>>,
13891) -> Result<(), NodeServerError> {
13892    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13893    loop {
13894        while connections.try_join_next().is_some() {}
13895        if shared.shutdown.load(Ordering::Acquire) {
13896            return Ok(());
13897        }
13898        let preauth_permit = tokio::select! {
13899            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13900                permit.map_err(|_| NodeServerError::PreauthClosed)?
13901            }
13902            _ = shared.shutdown_notify.notified() => return Ok(()),
13903        };
13904        let server = tokio::select! {
13905            result = listener.accept() => result?,
13906            _ = shared.shutdown_notify.notified() => return Ok(()),
13907        };
13908        let connection_shared = Arc::clone(&shared);
13909        connections.spawn(async move {
13910            serve_connection(server, connection_shared, None, preauth_permit).await
13911        });
13912        if shared.shutdown.load(Ordering::Acquire) {
13913            return Ok(());
13914        }
13915    }
13916}
13917
13918/// Serves one connection, whichever end opened it.
13919///
13920/// Generic over the stream rather than typed to `LocalServerStream`
13921/// because a call-home connection arrives on a `TcpStream` the node itself
13922/// dialled. Nothing in the body ever depended on the concrete type -- it
13923/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13924/// the whole of what welding "who dials" to "who serves" was costing.
13925async fn serve_connection<S>(
13926    mut pipe: S,
13927    shared: Arc<NodeShared>,
13928    session_token: Option<String>,
13929    preauth_permit: OwnedSemaphorePermit,
13930) -> Result<(), NodeServerError>
13931where
13932    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13933{
13934    // A signed call-home connection authenticates with its own per-connection token.
13935    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13936    let hello_frame = timeout(
13937        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13938        read_json_frame_limited_body_timeout(
13939            &mut pipe,
13940            MAX_NODE_HELLO_FRAME_BYTES,
13941            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13942        ),
13943    )
13944    .await
13945    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13946    let ClientFrame::Hello(hello) = hello_frame else {
13947        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13948    };
13949    if hello.build_stamp != BUILD_STAMP {
13950        return Err(NodeServerError::Handshake(format!(
13951            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13952            hello.build_stamp,
13953        )));
13954    }
13955    let compatibility = match hello.compatibility.as_ref() {
13956        Some(offer) => {
13957            let mut selected = node_compatibility_support(&shared)?
13958                .negotiate(offer)
13959                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13960            project_negotiated_provider_ids(&mut selected);
13961            Some(selected)
13962        }
13963        None => None,
13964    };
13965    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13966    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13967        (Some(offer), Some(selected)) => negotiated_auth_proof(
13968            auth_token,
13969            AuthDirection::Server,
13970            hello.role,
13971            &hello.client_nonce,
13972            &server_nonce,
13973            offer,
13974            selected,
13975        ),
13976        (None, None) => auth_proof(
13977            auth_token,
13978            AuthDirection::Server,
13979            hello.role,
13980            &hello.client_nonce,
13981            &server_nonce,
13982        ),
13983        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13984    }
13985    .map_err(NodeServerError::Authentication)?;
13986    write_json_frame_limited(
13987        &mut pipe,
13988        &ServerFrame::Challenge(ServerChallenge {
13989            build_stamp: BUILD_STAMP.to_owned(),
13990            server_nonce,
13991            server_proof,
13992            compatibility: compatibility.clone(),
13993        }),
13994        MAX_NODE_HELLO_FRAME_BYTES,
13995    )
13996    .await?;
13997    let authentication = timeout(
13998        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13999        read_json_frame_limited_body_timeout(
14000            &mut pipe,
14001            MAX_NODE_HELLO_FRAME_BYTES,
14002            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14003        ),
14004    )
14005    .await
14006    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14007    let ClientFrame::Authenticate(authentication) = authentication else {
14008        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14009    };
14010    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14011        (Some(offer), Some(selected)) => negotiated_auth_proof(
14012            auth_token,
14013            AuthDirection::Client,
14014            hello.role,
14015            &hello.client_nonce,
14016            &server_nonce,
14017            offer,
14018            selected,
14019        ),
14020        (None, None) => auth_proof(
14021            auth_token,
14022            AuthDirection::Client,
14023            hello.role,
14024            &hello.client_nonce,
14025            &server_nonce,
14026        ),
14027        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14028    }
14029    .map_err(NodeServerError::Authentication)?;
14030    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14031        return Err(NodeServerError::Handshake("access denied".to_owned()));
14032    }
14033    let selected_capabilities = compatibility
14034        .as_ref()
14035        .map(|selected| selected.capabilities.clone())
14036        .unwrap_or_default();
14037    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14038        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14039    });
14040    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14041        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14042    });
14043    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14044        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14045    });
14046    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14047        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14048    });
14049    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14050        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14051    });
14052    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14053        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14054    }) && selected_capabilities.iter().any(|capability| {
14055        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14056    });
14057    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14058        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14059    });
14060    let include_session_bundles = selected_capabilities.iter().any(|capability| {
14061        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14062    });
14063    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14064        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14065    });
14066    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14067        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14068    });
14069    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14070        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14071    });
14072    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14073        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14074    });
14075    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14076        .try_acquire_owned()
14077        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14078    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14079    let _release_guard = ControllerReleaseGuard {
14080        shared: Arc::clone(&shared),
14081        connection_id,
14082    };
14083    drop(preauth_permit);
14084    let _authenticated_permit = authenticated_permit;
14085    let mut event_rx = shared.event_tx.subscribe();
14086    let mut terminal_event_rx = terminal_event_subscription(
14087        &shared,
14088        include_terminal_frame_events,
14089    );
14090    let mut agent_stream_event_rx = agent_stream_event_subscription(
14091        &shared,
14092        include_agent_stream_events,
14093    );
14094    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14095        &shared,
14096        include_harness_mcp_proxy,
14097    );
14098    write_json_frame(
14099        &mut pipe,
14100        &ServerFrame::Hello(NodeHello {
14101            build_stamp: BUILD_STAMP.to_owned(),
14102            incarnation_id: shared.incarnation_id,
14103            connection_id,
14104            role: hello.role,
14105            event_sequence: shared.current_sequence(),
14106            controller: shared.controller_state(),
14107            snapshot: snapshot_for_wire(
14108                &shared,
14109                include_provider_runtime_status,
14110                include_open_provider_ids,
14111                include_spawn_profiles,
14112                include_managed_worktrees,
14113                include_child_environment_profiles,
14114                include_session_bundles,
14115                include_history_context_packs,
14116                include_agent_progress,
14117                include_session_task_correlation,
14118            ),
14119            compatibility,
14120        }),
14121    )
14122    .await?;
14123    let (mut reader, mut writer) = tokio::io::split(pipe);
14124    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14125    let reader_task = tokio::spawn(async move {
14126        loop {
14127            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14128                &mut reader,
14129                MAX_NODE_CLIENT_FRAME_BYTES,
14130                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14131            )
14132            .await;
14133            let terminal = frame.is_err();
14134            if frame_tx.send(frame).await.is_err() || terminal {
14135                break;
14136            }
14137        }
14138    });
14139    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14140    let mut pending_events = Vec::new();
14141    let mut resync_required = false;
14142    let mut discard_events_through = 0_u64;
14143    loop {
14144        // One tick of the serve loop, whether or not it had anything to do.
14145        // Against `connection_events_sent` this separates "busy" from
14146        // "spinning": a loop turning over thousands of times a second while
14147        // sending nothing is not serving, it is burning a core.
14148        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14149        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14150            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14151        for _ in 0..durable_drain_budget {
14152            match event_rx.try_recv() {
14153                Ok(event) => queue_connection_event(
14154                    &mut pending_events,
14155                    event,
14156                    discard_events_through,
14157                ),
14158                Err(broadcast::error::TryRecvError::Empty) => break,
14159                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14160                    resync_required = true;
14161                    break;
14162                }
14163                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14164            }
14165        }
14166        if let Some(receiver) = terminal_event_rx.as_mut() {
14167            match receiver.try_recv() {
14168                Ok(events) => queue_connection_event_batch(
14169                    &mut pending_events,
14170                    &events,
14171                    discard_events_through,
14172                ),
14173                Err(broadcast::error::TryRecvError::Empty) => {}
14174                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14175                    resync_required = true;
14176                }
14177                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14178            }
14179        }
14180        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14181            // Loops up to the same remaining-room budget the durable
14182            // `event_rx` drain above uses, rather than one `try_recv` per
14183            // tick: each successful receive here is itself only ONE
14184            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14185            // sends `vec![envelope]`, never more), so a single-shot drain
14186            // caps this connection's agent-stream throughput at one chunk
14187            // per outer-loop tick regardless of how many are already queued
14188            // -- exactly the kind of self-inflicted stall this crate's own
14189            // durable-event budget exists to avoid.
14190            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14191                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14192            for _ in 0..agent_stream_drain_budget {
14193                match receiver.try_recv() {
14194                    Ok(events) => queue_connection_event_batch(
14195                        &mut pending_events,
14196                        &events,
14197                        discard_events_through,
14198                    ),
14199                    Err(broadcast::error::TryRecvError::Empty) => break,
14200                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14201                        tracing::warn!(
14202                            connection_id,
14203                            dropped,
14204                            "agent stream event subscription lagged; forcing a resync",
14205                        );
14206                        resync_required = true;
14207                        break;
14208                    }
14209                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14210                }
14211            }
14212        }
14213        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14214            // Mirrors the agent-stream drain immediately above: each
14215            // successful receive is one single-envelope batch
14216            // (`publish_transient` always sends `vec![envelope]`), so this
14217            // loops up to the same remaining-room budget rather than
14218            // stopping after one `try_recv`.
14219            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14220                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14221            for _ in 0..harness_mcp_proxy_drain_budget {
14222                match receiver.try_recv() {
14223                    Ok(events) => queue_connection_event_batch(
14224                        &mut pending_events,
14225                        &events,
14226                        discard_events_through,
14227                    ),
14228                    Err(broadcast::error::TryRecvError::Empty) => break,
14229                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14230                        tracing::warn!(
14231                            connection_id,
14232                            dropped,
14233                            "harness MCP proxy event subscription lagged; forcing a resync",
14234                        );
14235                        resync_required = true;
14236                        break;
14237                    }
14238                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14239                }
14240            }
14241        }
14242        if resync_required {
14243            let event = resync_required_event(&shared);
14244            apply_connection_resync_watermark(
14245                &mut pending_events,
14246                &mut discard_events_through,
14247                event.sequence,
14248            );
14249            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14250            resync_required = false;
14251            continue;
14252        }
14253        if !pending_events.is_empty() {
14254            pending_events.sort_unstable_by_key(|event| event.sequence);
14255            let burst_len = connection_event_burst_len(pending_events.len());
14256            shared
14257                .connection_events_sent
14258                .fetch_add(burst_len as u64, Ordering::Relaxed);
14259            for event in pending_events.drain(..burst_len) {
14260                let event = if include_managed_worktrees {
14261                    Some(event)
14262                } else {
14263                    project_event_without_managed_worktrees(event)
14264                };
14265                let event = event.and_then(|event| if include_open_provider_ids {
14266                    Some(event)
14267                } else {
14268                    project_event_legacy_provider_ids(&shared, event)
14269                });
14270                let event = event.map(|event| if include_child_environment_profiles {
14271                    event
14272                } else {
14273                    project_event_without_child_environment_profile(event)
14274                });
14275                let event = event.map(|event| if include_session_bundles {
14276                    event
14277                } else {
14278                    project_event_without_session_bundle(event)
14279                });
14280                let event = event.map(|event| {
14281                    project_event_history_for_wire(event, include_history_context_packs)
14282                });
14283                let event = event.map(|event| if include_history_context_packs {
14284                    event
14285                } else {
14286                    project_event_without_context_pack(event)
14287                });
14288                let event = event.map(|event| if include_session_task_correlation {
14289                    event
14290                } else {
14291                    project_event_without_session_task_binding(event)
14292                });
14293                let exact_harness_controller = hello.role == ClientRole::Operator
14294                    && shared.controller_state().is_some_and(|controller| {
14295                        controller.connection_id == connection_id
14296                    });
14297                let event = event.and_then(|event| {
14298                    if include_harness_mcp_proxy && exact_harness_controller {
14299                        Some(event)
14300                    } else {
14301                        project_event_without_harness_mcp_proxy(event)
14302                    }
14303                });
14304                if let Some(event) = event {
14305                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14306                }
14307            }
14308        }
14309        tokio::select! {
14310            biased;
14311            frame = frame_rx.recv() => {
14312                let frame = match frame {
14313                    Some(frame) => frame,
14314                    None => break,
14315                };
14316                let frame = match frame {
14317                    Ok(frame) => frame,
14318                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14319                    Err(error) => return Err(error.into()),
14320                };
14321                let ClientFrame::Request(request) = frame else {
14322                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14323                };
14324                #[cfg(feature = "fixture")]
14325                let probe_spawn_managed_worktree_v2 = matches!(
14326                    &request.request,
14327                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14328                );
14329                let requires_open_provider_ids =
14330                    request_requires_open_provider_ids(&shared, &request.request);
14331                let requires_child_environment_profile =
14332                    request_requires_child_environment_profile(&shared, &request.request);
14333                let requires_session_bundle =
14334                    request_requires_session_bundle(&shared, &request.request);
14335                let requires_history_context_pack =
14336                    request_requires_history_context_pack(&shared, &request.request);
14337                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14338                    ResponseEnvelope {
14339                        request_id: request.request_id,
14340                        result: Err(failure(
14341                            NodeFailureCode::UnsupportedCapability,
14342                            "open provider IDs were not negotiated",
14343                        )),
14344                    }
14345                } else if requires_child_environment_profile
14346                    && !include_child_environment_profiles
14347                {
14348                    ResponseEnvelope {
14349                        request_id: request.request_id,
14350                        result: Err(failure(
14351                            NodeFailureCode::UnsupportedCapability,
14352                            "child environment profiles were not negotiated",
14353                        )),
14354                    }
14355                } else if requires_session_bundle && !include_session_bundles {
14356                    ResponseEnvelope {
14357                        request_id: request.request_id,
14358                        result: Err(failure(
14359                            NodeFailureCode::UnsupportedCapability,
14360                            "session bundles were not negotiated",
14361                        )),
14362                    }
14363                } else if requires_history_context_pack && !include_history_context_packs {
14364                    ResponseEnvelope {
14365                        request_id: request.request_id,
14366                        result: Err(failure(
14367                            NodeFailureCode::UnsupportedCapability,
14368                            "history context packs were not negotiated",
14369                        )),
14370                    }
14371                } else if request_uses_unnegotiated_capability(
14372                    &request.request,
14373                    &selected_capabilities,
14374                )
14375                {
14376                    ResponseEnvelope {
14377                        request_id: request.request_id,
14378                        result: Err(failure(
14379                            NodeFailureCode::UnsupportedCapability,
14380                            "request capability was not negotiated",
14381                        )),
14382                    }
14383                } else {
14384                    process_request(&shared, connection_id, hello.role, request).await
14385                };
14386                #[cfg(feature = "fixture")]
14387                if probe_spawn_managed_worktree_v2 {
14388                    shared
14389                        .fixture_spawn_managed_worktree_v2_failure_probe
14390                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14391                }
14392                if !include_provider_runtime_status {
14393                    clear_response_provider_runtime_status(&mut reply);
14394                }
14395                if !include_managed_worktrees {
14396                    project_response_without_managed_worktrees(&mut reply);
14397                }
14398                if !include_open_provider_ids {
14399                    project_response_legacy_provider_ids(&shared, &mut reply);
14400                }
14401                if !include_child_environment_profiles {
14402                    project_response_without_child_environment_profile(&mut reply);
14403                }
14404                if !include_session_bundles {
14405                    project_response_without_session_bundle(&mut reply);
14406                }
14407                project_response_history_for_wire(
14408                    &mut reply,
14409                    include_history_context_packs,
14410                );
14411                if !include_history_context_packs {
14412                    project_response_without_context_pack(&mut reply);
14413                }
14414                if !include_agent_progress {
14415                    project_response_without_agent_progress(&mut reply);
14416                }
14417                if !include_session_task_correlation {
14418                    project_response_without_session_task_binding(&mut reply);
14419                }
14420                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14421            }
14422            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14423            event = event_rx.recv() => {
14424                match event {
14425                    Ok(event) => queue_connection_event(
14426                        &mut pending_events,
14427                        event,
14428                        discard_events_through,
14429                    ),
14430                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14431                    Err(broadcast::error::RecvError::Closed) => break,
14432                }
14433            }
14434            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14435                match events {
14436                    Ok(events) => queue_connection_event_batch(
14437                        &mut pending_events,
14438                        &events,
14439                        discard_events_through,
14440                    ),
14441                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14442                    Err(broadcast::error::RecvError::Closed) => break,
14443                }
14444            }
14445            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14446                match events {
14447                    Ok(events) => queue_connection_event_batch(
14448                        &mut pending_events,
14449                        &events,
14450                        discard_events_through,
14451                    ),
14452                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14453                    Err(broadcast::error::RecvError::Closed) => break,
14454                }
14455            }
14456            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14457                match events {
14458                    Ok(events) => queue_connection_event_batch(
14459                        &mut pending_events,
14460                        &events,
14461                        discard_events_through,
14462                    ),
14463                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14464                    Err(broadcast::error::RecvError::Closed) => break,
14465                }
14466            }
14467            _ = shared.shutdown_notify.notified() => break,
14468        }
14469    }
14470    Ok(())
14471}
14472
14473async fn receive_terminal_event_batch(
14474    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14475) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14476    match receiver {
14477        Some(receiver) => receiver.recv().await,
14478        None => std::future::pending().await,
14479    }
14480}
14481
14482fn terminal_event_subscription(
14483    shared: &NodeShared,
14484    enabled: bool,
14485) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14486    enabled.then(|| shared.terminal_event_tx.subscribe())
14487}
14488
14489/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14490/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14491/// this needs a dedicated channel rather than a flag on the terminal one.
14492fn agent_stream_event_subscription(
14493    shared: &NodeShared,
14494    enabled: bool,
14495) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14496    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14497}
14498
14499/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14500/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14501/// own doc for why this needs a dedicated channel rather than a ride on
14502/// the durable `event_tx`.
14503fn harness_mcp_proxy_event_subscription(
14504    shared: &NodeShared,
14505    enabled: bool,
14506) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14507    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14508}
14509
14510fn queue_connection_event(
14511    pending: &mut Vec<NodeEventEnvelope>,
14512    event: NodeEventEnvelope,
14513    discard_through: u64,
14514) {
14515    if event.sequence <= discard_through
14516        && !event.event.requires_harness_mcp_proxy_capability()
14517    {
14518        return;
14519    }
14520    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14521        if let Some(index) = pending.iter().position(|current| {
14522            matches!(
14523                &current.event,
14524                NodeEvent::TerminalFrame {
14525                    address: current_address,
14526                    ..
14527                } if current_address == address
14528            )
14529        }) {
14530            pending.remove(index);
14531        }
14532    }
14533    pending.push(event);
14534}
14535
14536fn project_event_without_harness_mcp_proxy(
14537    envelope: NodeEventEnvelope,
14538) -> Option<NodeEventEnvelope> {
14539    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14540}
14541
14542fn queue_connection_event_batch(
14543    pending: &mut Vec<NodeEventEnvelope>,
14544    events: &[NodeEventEnvelope],
14545    discard_through: u64,
14546) {
14547    for event in events {
14548        queue_connection_event(pending, event.clone(), discard_through);
14549    }
14550}
14551
14552fn apply_connection_resync_watermark(
14553    pending: &mut Vec<NodeEventEnvelope>,
14554    discard_through: &mut u64,
14555    marker_sequence: u64,
14556) {
14557    *discard_through = (*discard_through).max(marker_sequence);
14558    pending.retain(|event| event.sequence > *discard_through);
14559}
14560
14561fn connection_event_burst_len(pending_len: usize) -> usize {
14562    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14563}
14564
14565fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14566    let history = shared
14567        .history
14568        .lock()
14569        .unwrap_or_else(|poisoned| poisoned.into_inner());
14570    let sequence = history.last_sequence;
14571    let oldest_available_sequence = history.replay_floor_sequence;
14572    NodeEventEnvelope {
14573        sequence,
14574        event: NodeEvent::ResyncRequired {
14575            oldest_available_sequence,
14576        },
14577    }
14578}
14579
14580fn node_compatibility_support(
14581    shared: &NodeShared,
14582) -> Result<NodeCompatibilitySupport, NodeServerError> {
14583    let mut support = node_compatibility_support_for_manifest(
14584        &shared.provider_contracts,
14585        &shared.provider_adapter_contracts,
14586    )?;
14587    if shared
14588        .delivery_store
14589        .lock()
14590        .unwrap_or_else(|poisoned| poisoned.into_inner())
14591        .is_some()
14592    {
14593        support.capabilities.push(
14594            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14595                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14596        );
14597    }
14598    if shared.harness_mcp_registry.is_some() {
14599        support.capabilities.push(
14600            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14601                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14602        );
14603    }
14604    Ok(support)
14605}
14606
14607fn node_compatibility_support_for_manifest(
14608    provider_contracts: &[ProviderContractSupport],
14609    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14610) -> Result<NodeCompatibilitySupport, NodeServerError> {
14611    Ok(NodeCompatibilitySupport {
14612        build_stamp: BUILD_STAMP.to_owned(),
14613        capabilities: baseline_capabilities()?,
14614        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14615        path_semantics: platform::path_semantics(),
14616        local_transport: platform::local_transport(),
14617        state_schema: StateSchemaSupport {
14618            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14619                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14620        },
14621        provider_contracts: provider_contracts.to_vec(),
14622        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14623    })
14624}
14625
14626fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14627    let capabilities = [
14628        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14629        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14630        NODE_REPOSITORY_PATH_CAPABILITY,
14631        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14632        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14633        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14634        NODE_GIT_READ_CAPABILITY,
14635        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14636        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14637        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14638        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14639        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14640        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14641        NODE_ACP_CONTROL_CAPABILITY,
14642        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14643        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14644        NODE_WORKTREE_SELECTION_CAPABILITY,
14645        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14646        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14647        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14648        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14649        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14650        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14651        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14652        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14653        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14654        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14655        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14656        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14657    ]
14658        .into_iter()
14659        .map(|capability| {
14660            CapabilityId::new(capability)
14661                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14662        })
14663        .collect::<Result<Vec<_>, _>>()?;
14664    #[cfg(windows)]
14665    let capabilities = {
14666        let mut capabilities = capabilities;
14667        capabilities.push(
14668            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14669                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14670        );
14671        capabilities
14672    };
14673    Ok(capabilities)
14674}
14675
14676fn request_uses_unnegotiated_capability(
14677    request: &NodeRequest,
14678    selected_capabilities: &[CapabilityId],
14679) -> bool {
14680    let selected = |required: &str| {
14681        selected_capabilities
14682            .iter()
14683            .any(|capability| capability.as_str() == required)
14684    };
14685    request
14686        .required_capability()
14687        .is_some_and(|required| !selected(required))
14688        || (request.requires_worktree_selection_capability()
14689            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14690        || (request.requires_spawn_spec_defaults_overrides_capability()
14691            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14692        || (request.requires_spawn_profile_revision_capability()
14693            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14694        || (request.requires_child_environment_profile_capability()
14695            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14696        || (request.requires_session_bundle_materialization_capability()
14697            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14698        || (request.requires_history_context_pack_capability()
14699            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14700}
14701
14702fn snapshot_for_wire(
14703    shared: &NodeShared,
14704    include_provider_runtime_status: bool,
14705    include_open_provider_ids: bool,
14706    include_spawn_profiles: bool,
14707    include_managed_worktrees: bool,
14708    include_child_environment_profiles: bool,
14709    include_session_bundles: bool,
14710    include_history_context_packs: bool,
14711    include_agent_progress: bool,
14712    include_session_task_correlation: bool,
14713) -> NodeSnapshot {
14714    let mut snapshot = shared.snapshot();
14715    if !include_provider_runtime_status {
14716        snapshot.provider_runtime_statuses.clear();
14717    }
14718    if !include_open_provider_ids {
14719        project_snapshot_legacy_provider_ids(&mut snapshot);
14720    }
14721    if !include_managed_worktrees {
14722        snapshot.managed_worktrees.clear();
14723        for workspace in &mut snapshot.workspaces {
14724            workspace.managed_worktree_profiles = None;
14725        }
14726    }
14727    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14728        if !include_spawn_profiles {
14729            inventory.spawn_profiles = None;
14730        }
14731        if !include_session_bundles {
14732            inventory.bundles = None;
14733        }
14734        if inventory.spawn_profiles.is_none()
14735            && inventory.bundles.is_none()
14736            && inventory.network_allowlists.is_none()
14737        {
14738            snapshot.launch_inventory = None;
14739        }
14740    }
14741    if !include_child_environment_profiles {
14742        clear_snapshot_child_environment_profiles(&mut snapshot);
14743    }
14744    if !include_session_bundles {
14745        clear_snapshot_session_bundles(&mut snapshot);
14746    }
14747    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14748    if !include_history_context_packs {
14749        clear_snapshot_context_packs(&mut snapshot);
14750    }
14751    if !include_agent_progress {
14752        snapshot.agent_progress.clear();
14753    }
14754    if !include_session_task_correlation {
14755        clear_snapshot_session_task_bindings(&mut snapshot);
14756    }
14757    snapshot
14758}
14759
14760fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14761    for record in &mut snapshot.session_records {
14762        record.task_binding = None;
14763    }
14764}
14765
14766fn project_event_without_session_task_binding(
14767    mut envelope: NodeEventEnvelope,
14768) -> NodeEventEnvelope {
14769    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14770        record.task_binding = None;
14771    }
14772    envelope
14773}
14774
14775fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14776    let Ok(response) = reply.result.as_mut() else { return };
14777    match response {
14778        NodeResponse::Snapshot { snapshot, .. } => {
14779            clear_snapshot_session_task_bindings(snapshot);
14780        }
14781        NodeResponse::Resync { snapshot, events, .. } => {
14782            clear_snapshot_session_task_bindings(snapshot);
14783            for event in events {
14784                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14785                    record.task_binding = None;
14786                }
14787            }
14788        }
14789        NodeResponse::SessionRecordUpdated { record }
14790        | NodeResponse::ProviderSessionIndexed { record }
14791        | NodeResponse::NativeSessionIndexed { record, .. }
14792        | NodeResponse::SessionRecordResumed { record, .. } => {
14793            record.task_binding = None;
14794        }
14795        _ => {}
14796    }
14797}
14798
14799fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14800    let Ok(response) = reply.result.as_mut() else { return };
14801    match response {
14802        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14803            snapshot.agent_progress.clear();
14804        }
14805        _ => {}
14806    }
14807}
14808
14809fn project_event_without_managed_worktrees(
14810    mut envelope: NodeEventEnvelope,
14811) -> Option<NodeEventEnvelope> {
14812    if matches!(
14813        envelope.event,
14814        NodeEvent::ManagedWorktreeUpserted { .. }
14815            | NodeEvent::ManagedWorktreeRemoved { .. }
14816    ) {
14817        return None;
14818    }
14819    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14820        workspace.managed_worktree_profiles = None;
14821    }
14822    Some(envelope)
14823}
14824
14825fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14826    if matches!(
14827        reply.result,
14828        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14829            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14830    ) {
14831        reply.result = Err(failure(
14832            NodeFailureCode::UnsupportedCapability,
14833            "managed worktree capability was not negotiated",
14834        ));
14835        return;
14836    }
14837    let Ok(response) = reply.result.as_mut() else { return };
14838    match response {
14839        NodeResponse::Snapshot { snapshot, .. } => {
14840            snapshot.managed_worktrees.clear();
14841            for workspace in &mut snapshot.workspaces {
14842                workspace.managed_worktree_profiles = None;
14843            }
14844        }
14845        NodeResponse::Resync { snapshot, events, .. } => {
14846            snapshot.managed_worktrees.clear();
14847            for workspace in &mut snapshot.workspaces {
14848                workspace.managed_worktree_profiles = None;
14849            }
14850            *events = std::mem::take(events)
14851                .into_iter()
14852                .filter_map(project_event_without_managed_worktrees)
14853                .collect();
14854        }
14855        NodeResponse::WorkspaceRegistered { workspace }
14856        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14857        | NodeResponse::WorktreeCreated { workspace, .. } => {
14858            workspace.managed_worktree_profiles = None;
14859        }
14860        NodeResponse::WorkspaceInspected { inspection } => {
14861            inspection.git.managed_worktree = None;
14862        }
14863        _ => {}
14864    }
14865}
14866
14867fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14868    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14869        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14870    });
14871    if includes_open_provider_ids {
14872        return;
14873    }
14874    compatibility
14875        .provider_contracts
14876        .retain(|contract| provider_id_is_legacy(&contract.provider));
14877    compatibility
14878        .provider_adapter_contracts
14879        .retain(|contract| provider_id_is_legacy(&contract.provider));
14880}
14881
14882fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14883    if let Some(spec) = match request {
14884        NodeRequest::SpawnSpec { spec }
14885        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14886        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14887        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14888        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14889        _ => None,
14890    } {
14891        return shared
14892            .resolve_spawn_spec(spec)
14893            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14894    }
14895    request_requires_open_provider_ids_with(
14896        request,
14897        |session| shared.validate_address(session).ok(),
14898        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14899    )
14900}
14901
14902fn request_requires_child_environment_profile(
14903    shared: &NodeShared,
14904    request: &NodeRequest,
14905) -> bool {
14906    if let Some(spec) = match request {
14907        NodeRequest::SpawnSpec { spec }
14908        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14909        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14910        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14911        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14912        _ => None,
14913    } {
14914        return shared
14915            .resolve_spawn_spec(spec)
14916            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14917    }
14918    match request {
14919        NodeRequest::Resume { session, .. }
14920        | NodeRequest::Prompt { session, .. }
14921        | NodeRequest::Paste { session, .. }
14922        | NodeRequest::Input { session, .. }
14923        | NodeRequest::TerminalBytes { session, .. }
14924        | NodeRequest::TerminalControl { session, .. }
14925        | NodeRequest::Resize { session, .. }
14926        | NodeRequest::Interrupt { session }
14927        | NodeRequest::Stop { session, .. }
14928        | NodeRequest::Remove { session }
14929        | NodeRequest::ResolveInteraction { session, .. }
14930        | NodeRequest::SetSessionMode { session, .. }
14931        | NodeRequest::SetSessionConfigOption { session, .. }
14932        | NodeRequest::SetSessionModel { session, .. }
14933        | NodeRequest::DiscoverHistory { session, .. }
14934        | NodeRequest::LoadHistory { session, .. }
14935        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14936        | NodeRequest::ExportContextPack { session } => shared
14937            .session_bindings
14938            .lock()
14939            .unwrap_or_else(|poisoned| poisoned.into_inner())
14940            .get(&session.session.instance_id)
14941            .filter(|binding| {
14942                binding.workspace_id == session.workspace_id
14943                    && binding.generation == session.session.generation
14944            })
14945            .map_or(true, |binding| binding.environment_profile.is_some()),
14946        NodeRequest::RenameSessionRecord { record_id, .. }
14947        | NodeRequest::SetSessionTask { record_id, .. }
14948        | NodeRequest::ResumeSessionRecord { record_id, .. }
14949        | NodeRequest::ForgetSessionRecord { record_id } => shared
14950            .record(record_id)
14951            .map_or(true, |record| record.environment_profile.is_some()),
14952        NodeRequest::ArmHarnessMcpReservation { .. }
14953        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14954        | NodeRequest::ActivateHarnessMcpReservation { .. }
14955        | NodeRequest::AbortHarnessMcpReservation { .. }
14956        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14957        | NodeRequest::RejectHarnessMcpCall { .. }
14958        | NodeRequest::Snapshot
14959        | NodeRequest::Resync { .. }
14960        | NodeRequest::BeginDeliveryStage { .. }
14961        | NodeRequest::PutDeliveryBlobChunk { .. }
14962        | NodeRequest::CommitDeliveryStage { .. }
14963        | NodeRequest::AbortDeliveryStage { .. }
14964        | NodeRequest::BrowseHostDirectories { .. }
14965        | NodeRequest::InspectWorkspace { .. }
14966        | NodeRequest::ReadWorkspaceFile { .. }
14967        | NodeRequest::WriteWorkspaceFile { .. }
14968        | NodeRequest::CreateWorkspaceFile { .. }
14969        | NodeRequest::CreateWorkspaceDirectory { .. }
14970        | NodeRequest::ReadGitHistory { .. }
14971        | NodeRequest::ReadGitDiff { .. }
14972        | NodeRequest::AcquireController { .. }
14973        | NodeRequest::ReleaseController
14974        | NodeRequest::Spawn { .. }
14975        | NodeRequest::SpawnWithOptions { .. }
14976        | NodeRequest::SpawnSpec { .. }
14977        | NodeRequest::RegisterWorkspace { .. }
14978        | NodeRequest::CreateStandaloneWorkspace { .. }
14979        | NodeRequest::UnregisterWorkspace { .. }
14980        | NodeRequest::CreateWorktree { .. }
14981        | NodeRequest::RemoveWorktree { .. }
14982        | NodeRequest::SpawnManagedWorktree { .. }
14983        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14984        | NodeRequest::CleanupManagedWorktree { .. }
14985        | NodeRequest::IndexProviderSession { .. }
14986        | NodeRequest::IndexNativeSession { .. }
14987        | NodeRequest::CatalogNativeSessions { .. }
14988        | NodeRequest::PageNativeSessions { .. }
14989        | NodeRequest::PreviewNativeSession { .. }
14990        | NodeRequest::PreviewSessionRecord { .. }
14991        | NodeRequest::ForgetContextPack { .. }
14992        | NodeRequest::ResolveDurableContextPack { .. }
14993        | NodeRequest::ReadContextPack { .. }
14994        | NodeRequest::Shutdown => false,
14995    }
14996}
14997
14998fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14999    if let Some(spec) = match request {
15000        NodeRequest::SpawnSpec { spec }
15001        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15002        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15003        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15004        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15005        _ => None,
15006    } {
15007        return shared
15008            .resolve_spawn_spec(spec)
15009            .is_ok_and(|resolved| resolved.bundle_id.is_some());
15010    }
15011    match request {
15012        NodeRequest::Resume { session, .. }
15013        | NodeRequest::Prompt { session, .. }
15014        | NodeRequest::Paste { session, .. }
15015        | NodeRequest::Input { session, .. }
15016        | NodeRequest::TerminalBytes { session, .. }
15017        | NodeRequest::TerminalControl { session, .. }
15018        | NodeRequest::Resize { session, .. }
15019        | NodeRequest::Interrupt { session }
15020        | NodeRequest::Stop { session, .. }
15021        | NodeRequest::Remove { session }
15022        | NodeRequest::ResolveInteraction { session, .. }
15023        | NodeRequest::SetSessionMode { session, .. }
15024        | NodeRequest::SetSessionConfigOption { session, .. }
15025        | NodeRequest::SetSessionModel { session, .. }
15026        | NodeRequest::DiscoverHistory { session, .. }
15027        | NodeRequest::LoadHistory { session, .. }
15028        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15029        | NodeRequest::ExportContextPack { session } => shared
15030            .session_bindings
15031            .lock()
15032            .unwrap_or_else(|poisoned| poisoned.into_inner())
15033            .get(&session.session.instance_id)
15034            .filter(|binding| {
15035                binding.workspace_id == session.workspace_id
15036                    && binding.generation == session.session.generation
15037            })
15038            .map_or(true, |binding| binding.bundle.is_some()),
15039        NodeRequest::RenameSessionRecord { record_id, .. }
15040        | NodeRequest::SetSessionTask { record_id, .. }
15041        | NodeRequest::ResumeSessionRecord { record_id, .. }
15042        | NodeRequest::ForgetSessionRecord { record_id } => shared
15043            .record(record_id)
15044            .map_or(true, |record| record.bundle.is_some()),
15045        NodeRequest::ArmHarnessMcpReservation { .. }
15046        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15047        | NodeRequest::ActivateHarnessMcpReservation { .. }
15048        | NodeRequest::AbortHarnessMcpReservation { .. }
15049        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15050        | NodeRequest::RejectHarnessMcpCall { .. }
15051        | NodeRequest::Snapshot
15052        | NodeRequest::Resync { .. }
15053        | NodeRequest::BeginDeliveryStage { .. }
15054        | NodeRequest::PutDeliveryBlobChunk { .. }
15055        | NodeRequest::CommitDeliveryStage { .. }
15056        | NodeRequest::AbortDeliveryStage { .. }
15057        | NodeRequest::BrowseHostDirectories { .. }
15058        | NodeRequest::InspectWorkspace { .. }
15059        | NodeRequest::ReadWorkspaceFile { .. }
15060        | NodeRequest::WriteWorkspaceFile { .. }
15061        | NodeRequest::CreateWorkspaceFile { .. }
15062        | NodeRequest::CreateWorkspaceDirectory { .. }
15063        | NodeRequest::ReadGitHistory { .. }
15064        | NodeRequest::ReadGitDiff { .. }
15065        | NodeRequest::AcquireController { .. }
15066        | NodeRequest::ReleaseController
15067        | NodeRequest::Spawn { .. }
15068        | NodeRequest::SpawnWithOptions { .. }
15069        | NodeRequest::SpawnSpec { .. }
15070        | NodeRequest::RegisterWorkspace { .. }
15071        | NodeRequest::CreateStandaloneWorkspace { .. }
15072        | NodeRequest::UnregisterWorkspace { .. }
15073        | NodeRequest::CreateWorktree { .. }
15074        | NodeRequest::RemoveWorktree { .. }
15075        | NodeRequest::SpawnManagedWorktree { .. }
15076        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15077        | NodeRequest::CleanupManagedWorktree { .. }
15078        | NodeRequest::IndexProviderSession { .. }
15079        | NodeRequest::IndexNativeSession { .. }
15080        | NodeRequest::CatalogNativeSessions { .. }
15081        | NodeRequest::PageNativeSessions { .. }
15082        | NodeRequest::PreviewNativeSession { .. }
15083        | NodeRequest::PreviewSessionRecord { .. }
15084        | NodeRequest::ForgetContextPack { .. }
15085        | NodeRequest::ResolveDurableContextPack { .. }
15086        | NodeRequest::ReadContextPack { .. }
15087        | NodeRequest::Shutdown => false,
15088    }
15089}
15090
15091fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15092    if request.requires_history_context_pack_capability() {
15093        return true;
15094    }
15095    if let Some(spec) = match request {
15096        NodeRequest::SpawnSpec { spec }
15097        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15098        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15099        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15100        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15101        _ => None,
15102    } {
15103        return shared
15104            .resolve_spawn_spec(spec)
15105            .map_or(true, |resolved| resolved.context_id.is_some());
15106    }
15107    match request {
15108        NodeRequest::Resume { session, .. }
15109        | NodeRequest::Prompt { session, .. }
15110        | NodeRequest::Paste { session, .. }
15111        | NodeRequest::Input { session, .. }
15112        | NodeRequest::TerminalBytes { session, .. }
15113        | NodeRequest::TerminalControl { session, .. }
15114        | NodeRequest::Resize { session, .. }
15115        | NodeRequest::Interrupt { session }
15116        | NodeRequest::Stop { session, .. }
15117        | NodeRequest::Remove { session }
15118        | NodeRequest::ResolveInteraction { session, .. }
15119        | NodeRequest::SetSessionMode { session, .. }
15120        | NodeRequest::SetSessionConfigOption { session, .. }
15121        | NodeRequest::SetSessionModel { session, .. } => shared
15122            .session_bindings
15123            .lock()
15124            .unwrap_or_else(|poisoned| poisoned.into_inner())
15125            .get(&session.session.instance_id)
15126            .filter(|binding| {
15127                binding.workspace_id == session.workspace_id
15128                    && binding.generation == session.session.generation
15129            })
15130            .map_or(true, |binding| binding.context.is_some()),
15131        NodeRequest::RenameSessionRecord { record_id, .. }
15132        | NodeRequest::SetSessionTask { record_id, .. }
15133        | NodeRequest::ResumeSessionRecord { record_id, .. }
15134        | NodeRequest::ForgetSessionRecord { record_id } => shared
15135            .record(record_id)
15136            .map_or(true, |record| record.context.is_some()),
15137        NodeRequest::ArmHarnessMcpReservation { .. }
15138        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15139        | NodeRequest::ActivateHarnessMcpReservation { .. }
15140        | NodeRequest::AbortHarnessMcpReservation { .. }
15141        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15142        | NodeRequest::RejectHarnessMcpCall { .. }
15143        | NodeRequest::Snapshot
15144        | NodeRequest::Resync { .. }
15145        | NodeRequest::BeginDeliveryStage { .. }
15146        | NodeRequest::PutDeliveryBlobChunk { .. }
15147        | NodeRequest::CommitDeliveryStage { .. }
15148        | NodeRequest::AbortDeliveryStage { .. }
15149        | NodeRequest::BrowseHostDirectories { .. }
15150        | NodeRequest::InspectWorkspace { .. }
15151        | NodeRequest::ReadWorkspaceFile { .. }
15152        | NodeRequest::WriteWorkspaceFile { .. }
15153        | NodeRequest::CreateWorkspaceFile { .. }
15154        | NodeRequest::CreateWorkspaceDirectory { .. }
15155        | NodeRequest::ReadGitHistory { .. }
15156        | NodeRequest::ReadGitDiff { .. }
15157        | NodeRequest::AcquireController { .. }
15158        | NodeRequest::ReleaseController
15159        | NodeRequest::Spawn { .. }
15160        | NodeRequest::SpawnWithOptions { .. }
15161        | NodeRequest::SpawnSpec { .. }
15162        | NodeRequest::RegisterWorkspace { .. }
15163        | NodeRequest::CreateStandaloneWorkspace { .. }
15164        | NodeRequest::UnregisterWorkspace { .. }
15165        | NodeRequest::CreateWorktree { .. }
15166        | NodeRequest::RemoveWorktree { .. }
15167        | NodeRequest::SpawnManagedWorktree { .. }
15168        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15169        | NodeRequest::CleanupManagedWorktree { .. }
15170        | NodeRequest::IndexProviderSession { .. }
15171        | NodeRequest::IndexNativeSession { .. }
15172        | NodeRequest::CatalogNativeSessions { .. }
15173        | NodeRequest::PageNativeSessions { .. }
15174        | NodeRequest::PreviewNativeSession { .. }
15175        | NodeRequest::PreviewSessionRecord { .. }
15176        | NodeRequest::DiscoverHistory { .. }
15177        | NodeRequest::LoadHistory { .. }
15178        | NodeRequest::ExportContextPackForSessionRecord { .. }
15179        | NodeRequest::ExportContextPack { .. }
15180        | NodeRequest::ForgetContextPack { .. }
15181        | NodeRequest::ResolveDurableContextPack { .. }
15182        | NodeRequest::ReadContextPack { .. }
15183        | NodeRequest::Shutdown => false,
15184    }
15185}
15186
15187fn request_requires_open_provider_ids_with(
15188    request: &NodeRequest,
15189    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15190    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15191) -> bool {
15192    match request {
15193        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15194        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15195        NodeRequest::CatalogNativeSessions { route, .. }
15196        | NodeRequest::PageNativeSessions { route, .. } => {
15197            !provider_id_is_legacy(&route.provider)
15198        }
15199        NodeRequest::PreviewNativeSession { selection, .. }
15200        | NodeRequest::IndexNativeSession { selection, .. } => {
15201            !provider_id_is_legacy(&selection.route.provider)
15202        }
15203        NodeRequest::SpawnSpec { .. }
15204        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15205        NodeRequest::SpawnManagedWorktree { .. }
15206        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15207        NodeRequest::Resume { session, .. }
15208        | NodeRequest::Prompt { session, .. }
15209        | NodeRequest::Paste { session, .. }
15210        | NodeRequest::Input { session, .. }
15211        | NodeRequest::TerminalBytes { session, .. }
15212        | NodeRequest::TerminalControl { session, .. }
15213        | NodeRequest::Resize { session, .. }
15214        | NodeRequest::Interrupt { session }
15215        | NodeRequest::Stop { session, .. }
15216        | NodeRequest::Remove { session }
15217        | NodeRequest::ResolveInteraction { session, .. }
15218        | NodeRequest::SetSessionMode { session, .. }
15219        | NodeRequest::SetSessionConfigOption { session, .. }
15220        | NodeRequest::SetSessionModel { session, .. }
15221        | NodeRequest::DiscoverHistory { session, .. }
15222        | NodeRequest::LoadHistory { session, .. }
15223        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15224        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15225            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15226        NodeRequest::RenameSessionRecord { record_id, .. }
15227        | NodeRequest::SetSessionTask { record_id, .. }
15228        | NodeRequest::ResumeSessionRecord { record_id, .. }
15229        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15230            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15231        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15232            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15233        NodeRequest::ArmHarnessMcpReservation { .. }
15234        | NodeRequest::ActivateHarnessMcpReservation { .. }
15235        | NodeRequest::AbortHarnessMcpReservation { .. }
15236        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15237        | NodeRequest::RejectHarnessMcpCall { .. }
15238        | NodeRequest::Snapshot
15239        | NodeRequest::Resync { .. }
15240        | NodeRequest::BeginDeliveryStage { .. }
15241        | NodeRequest::PutDeliveryBlobChunk { .. }
15242        | NodeRequest::CommitDeliveryStage { .. }
15243        | NodeRequest::AbortDeliveryStage { .. }
15244        | NodeRequest::BrowseHostDirectories { .. }
15245        | NodeRequest::InspectWorkspace { .. }
15246        | NodeRequest::ReadWorkspaceFile { .. }
15247        | NodeRequest::WriteWorkspaceFile { .. }
15248        | NodeRequest::CreateWorkspaceFile { .. }
15249        | NodeRequest::CreateWorkspaceDirectory { .. }
15250        | NodeRequest::ReadGitHistory { .. }
15251        | NodeRequest::ReadGitDiff { .. }
15252        | NodeRequest::AcquireController { .. }
15253        | NodeRequest::ReleaseController
15254        | NodeRequest::RegisterWorkspace { .. }
15255        | NodeRequest::CreateStandaloneWorkspace { .. }
15256        | NodeRequest::UnregisterWorkspace { .. }
15257        | NodeRequest::CreateWorktree { .. }
15258        | NodeRequest::RemoveWorktree { .. }
15259        | NodeRequest::CleanupManagedWorktree { .. }
15260        | NodeRequest::Shutdown => false,
15261        NodeRequest::ForgetContextPack { .. } => true,
15262        // No session/record to cheaply resolve a provider from here either;
15263        // the resolved receipt can carry an open-id provider, so require the
15264        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15265        // is the same digest-keyed lookup one step further into the same
15266        // catalog, so it inherits the same conservative answer.
15267        NodeRequest::ResolveDurableContextPack { .. }
15268        | NodeRequest::ReadContextPack { .. } => true,
15269    }
15270}
15271
15272fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15273    snapshot.enabled_providers.retain(provider_id_is_legacy);
15274    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15275        snapshot
15276            .provider_runtime_statuses
15277            .as_slice()
15278            .iter()
15279            .filter(|status| provider_id_is_legacy(status.provider()))
15280            .cloned(),
15281    )
15282    .expect("a filtered provider runtime projection remains valid");
15283    snapshot
15284        .session_records
15285        .retain(|record| provider_id_is_legacy(&record.provider));
15286    for workspace in &mut snapshot.workspaces {
15287        workspace
15288            .sessions
15289            .retain(|session| provider_id_is_legacy(&session.agent_id));
15290    }
15291}
15292
15293fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15294    for record in &mut snapshot.session_records {
15295        record.environment_profile = None;
15296    }
15297    if let Some(profiles) = snapshot
15298        .launch_inventory
15299        .as_mut()
15300        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15301    {
15302        for profile in profiles {
15303            profile.environment_profile = None;
15304        }
15305    }
15306}
15307
15308fn project_event_without_child_environment_profile(
15309    mut envelope: NodeEventEnvelope,
15310) -> NodeEventEnvelope {
15311    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15312        record.environment_profile = None;
15313    }
15314    envelope
15315}
15316
15317fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15318    let contains_environment_profile = match reply.result.as_ref() {
15319        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15320            receipt.environment_profile.is_some()
15321        }
15322        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15323            receipt.spawn.environment_profile.is_some()
15324        }
15325        _ => false,
15326    };
15327    if contains_environment_profile {
15328        reply.result = Err(failure(
15329            NodeFailureCode::UnsupportedCapability,
15330            "child environment profiles were not negotiated",
15331        ));
15332        return;
15333    }
15334    let Ok(response) = reply.result.as_mut() else {
15335        return;
15336    };
15337    match response {
15338        NodeResponse::Snapshot { snapshot, .. } => {
15339            clear_snapshot_child_environment_profiles(snapshot);
15340        }
15341        NodeResponse::Resync { snapshot, events, .. } => {
15342            clear_snapshot_child_environment_profiles(snapshot);
15343            for event in events {
15344                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15345                    record.environment_profile = None;
15346                }
15347            }
15348        }
15349        NodeResponse::SessionRecordUpdated { record }
15350        | NodeResponse::ProviderSessionIndexed { record }
15351        | NodeResponse::NativeSessionIndexed { record, .. }
15352        | NodeResponse::SessionRecordResumed { record, .. } => {
15353            record.environment_profile = None;
15354        }
15355        NodeResponse::Armed { .. }
15356        | NodeResponse::Spawned { .. }
15357        | NodeResponse::Activated { .. }
15358        | NodeResponse::Aborted { .. }
15359        | NodeResponse::ReplyChunkAccepted { .. }
15360        | NodeResponse::CallRejected { .. }
15361        | NodeResponse::DeliveryStageBegun { .. }
15362        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15363        | NodeResponse::DeliveryCommitted { .. }
15364        | NodeResponse::DeliveryStageAborted { .. }
15365        | NodeResponse::WorkspaceInspected { .. }
15366        | NodeResponse::HostDirectoriesBrowsed { .. }
15367        | NodeResponse::WorkspaceFileRead { .. }
15368        | NodeResponse::WorkspaceFileWritten { .. }
15369        | NodeResponse::WorkspaceFileCreated { .. }
15370        | NodeResponse::WorkspaceDirectoryCreated { .. }
15371        | NodeResponse::GitHistoryRead { .. }
15372        | NodeResponse::GitDiffRead { .. }
15373        | NodeResponse::Controller { .. }
15374        | NodeResponse::SpawnAccepted { .. }
15375        | NodeResponse::SpawnSpecAccepted { .. }
15376        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15377        | NodeResponse::ManagedWorktreeCleanup { .. }
15378        | NodeResponse::NativeSessionsCataloged { .. }
15379        | NodeResponse::NativeSessionsPaged { .. }
15380        | NodeResponse::NativeSessionPreviewed { .. }
15381        | NodeResponse::SessionRecordPreviewed { .. }
15382        | NodeResponse::HistoryDiscovered { .. }
15383        | NodeResponse::HistoryLoaded { .. }
15384        | NodeResponse::ContextPackForSessionRecordExported { .. }
15385        | NodeResponse::ContextPackExported { .. }
15386        | NodeResponse::ContextPackForgotten { .. }
15387        | NodeResponse::DurableContextPackResolved { .. }
15388        | NodeResponse::ContextPackBytesRead { .. }
15389        | NodeResponse::SessionRecordForgotten { .. }
15390        | NodeResponse::WorkspaceRegistered { .. }
15391        | NodeResponse::StandaloneWorkspaceCreated { .. }
15392        | NodeResponse::WorkspaceUnregistered { .. }
15393        | NodeResponse::WorktreeCreated { .. }
15394        | NodeResponse::WorktreeRemoved { .. }
15395        | NodeResponse::Accepted
15396        | NodeResponse::ShuttingDown => {}
15397    }
15398}
15399
15400fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15401    for record in &mut snapshot.session_records {
15402        record.bundle = None;
15403    }
15404}
15405
15406fn project_event_without_session_bundle(
15407    mut envelope: NodeEventEnvelope,
15408) -> NodeEventEnvelope {
15409    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15410        record.bundle = None;
15411    }
15412    envelope
15413}
15414
15415fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15416    let contains_bundle = match reply.result.as_ref() {
15417        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15418        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15419            receipt.spawn.bundle.is_some()
15420        }
15421        _ => false,
15422    };
15423    if contains_bundle {
15424        reply.result = Err(failure(
15425            NodeFailureCode::UnsupportedCapability,
15426            "session bundles were not negotiated",
15427        ));
15428        return;
15429    }
15430    let Ok(response) = reply.result.as_mut() else {
15431        return;
15432    };
15433    match response {
15434        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15435        NodeResponse::Resync { snapshot, events, .. } => {
15436            clear_snapshot_session_bundles(snapshot);
15437            for event in events {
15438                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15439                    record.bundle = None;
15440                }
15441            }
15442        }
15443        NodeResponse::SessionRecordUpdated { record }
15444        | NodeResponse::ProviderSessionIndexed { record }
15445        | NodeResponse::NativeSessionIndexed { record, .. }
15446        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15447        NodeResponse::Armed { .. }
15448        | NodeResponse::Spawned { .. }
15449        | NodeResponse::Activated { .. }
15450        | NodeResponse::Aborted { .. }
15451        | NodeResponse::ReplyChunkAccepted { .. }
15452        | NodeResponse::CallRejected { .. }
15453        | NodeResponse::DeliveryStageBegun { .. }
15454        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15455        | NodeResponse::DeliveryCommitted { .. }
15456        | NodeResponse::DeliveryStageAborted { .. }
15457        | NodeResponse::WorkspaceInspected { .. }
15458        | NodeResponse::HostDirectoriesBrowsed { .. }
15459        | NodeResponse::WorkspaceFileRead { .. }
15460        | NodeResponse::WorkspaceFileWritten { .. }
15461        | NodeResponse::WorkspaceFileCreated { .. }
15462        | NodeResponse::WorkspaceDirectoryCreated { .. }
15463        | NodeResponse::GitHistoryRead { .. }
15464        | NodeResponse::GitDiffRead { .. }
15465        | NodeResponse::Controller { .. }
15466        | NodeResponse::SpawnAccepted { .. }
15467        | NodeResponse::SpawnSpecAccepted { .. }
15468        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15469        | NodeResponse::ManagedWorktreeCleanup { .. }
15470        | NodeResponse::NativeSessionsCataloged { .. }
15471        | NodeResponse::NativeSessionsPaged { .. }
15472        | NodeResponse::NativeSessionPreviewed { .. }
15473        | NodeResponse::SessionRecordPreviewed { .. }
15474        | NodeResponse::HistoryDiscovered { .. }
15475        | NodeResponse::HistoryLoaded { .. }
15476        | NodeResponse::ContextPackForSessionRecordExported { .. }
15477        | NodeResponse::ContextPackExported { .. }
15478        | NodeResponse::ContextPackForgotten { .. }
15479        | NodeResponse::DurableContextPackResolved { .. }
15480        | NodeResponse::ContextPackBytesRead { .. }
15481        | NodeResponse::SessionRecordForgotten { .. }
15482        | NodeResponse::WorkspaceRegistered { .. }
15483        | NodeResponse::StandaloneWorkspaceCreated { .. }
15484        | NodeResponse::WorkspaceUnregistered { .. }
15485        | NodeResponse::WorktreeCreated { .. }
15486        | NodeResponse::WorktreeRemoved { .. }
15487        | NodeResponse::Accepted
15488        | NodeResponse::ShuttingDown => {}
15489    }
15490}
15491
15492fn project_snapshot_history_for_wire(
15493    snapshot: &mut NodeSnapshot,
15494    include_history_metadata: bool,
15495) {
15496    for workspace in &mut snapshot.workspaces {
15497        for session in &mut workspace.sessions {
15498            if include_history_metadata {
15499                session.history.loaded = None;
15500            } else {
15501                session.history = Default::default();
15502            }
15503        }
15504    }
15505}
15506
15507fn project_event_history_for_wire(
15508    mut envelope: NodeEventEnvelope,
15509    include_history_metadata: bool,
15510) -> NodeEventEnvelope {
15511    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15512        for session in &mut workspace.sessions {
15513            if include_history_metadata {
15514                session.history.loaded = None;
15515            } else {
15516                session.history = Default::default();
15517            }
15518        }
15519    }
15520    envelope
15521}
15522
15523fn project_response_history_for_wire(
15524    reply: &mut ResponseEnvelope,
15525    include_history_metadata: bool,
15526) {
15527    let Ok(response) = reply.result.as_mut() else {
15528        return;
15529    };
15530    match response {
15531        NodeResponse::Snapshot { snapshot, .. } => {
15532            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15533        }
15534        NodeResponse::Resync {
15535            snapshot, events, ..
15536        } => {
15537            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15538            for event in events {
15539                *event = project_event_history_for_wire(
15540                    event.clone(),
15541                    include_history_metadata,
15542                );
15543            }
15544        }
15545        NodeResponse::WorkspaceRegistered { workspace }
15546        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15547        | NodeResponse::WorktreeCreated { workspace, .. } => {
15548            for session in &mut workspace.sessions {
15549                if include_history_metadata {
15550                    session.history.loaded = None;
15551                } else {
15552                    session.history = Default::default();
15553                }
15554            }
15555        }
15556        _ => {}
15557    }
15558}
15559
15560fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15561    for record in &mut snapshot.session_records {
15562        record.context_id = None;
15563        record.context = None;
15564        record.exported_context = None;
15565    }
15566}
15567
15568fn project_event_without_context_pack(
15569    mut envelope: NodeEventEnvelope,
15570) -> NodeEventEnvelope {
15571    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15572        record.context_id = None;
15573        record.context = None;
15574        record.exported_context = None;
15575    }
15576    envelope
15577}
15578
15579fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15580    let contains_context = match reply.result.as_ref() {
15581        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15582        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15583            receipt.spawn.context.is_some()
15584        }
15585        Ok(NodeResponse::HistoryDiscovered { .. })
15586        | Ok(NodeResponse::HistoryLoaded { .. })
15587        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15588        | Ok(NodeResponse::ContextPackExported { .. })
15589        | Ok(NodeResponse::ContextPackForgotten { .. })
15590        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15591        _ => false,
15592    };
15593    if contains_context {
15594        reply.result = Err(failure(
15595            NodeFailureCode::UnsupportedCapability,
15596            "history context packs were not negotiated",
15597        ));
15598        return;
15599    }
15600    let Ok(response) = reply.result.as_mut() else {
15601        return;
15602    };
15603    match response {
15604        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15605        NodeResponse::Resync {
15606            snapshot, events, ..
15607        } => {
15608            clear_snapshot_context_packs(snapshot);
15609            for event in events {
15610                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15611                    record.context_id = None;
15612                    record.context = None;
15613                    record.exported_context = None;
15614                }
15615            }
15616        }
15617        NodeResponse::SessionRecordUpdated { record }
15618        | NodeResponse::ProviderSessionIndexed { record }
15619        | NodeResponse::NativeSessionIndexed { record, .. }
15620        | NodeResponse::SessionRecordResumed { record, .. } => {
15621            record.context_id = None;
15622            record.context = None;
15623            record.exported_context = None;
15624        }
15625        _ => {}
15626    }
15627}
15628
15629fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15630    let contains_open_record = match reply.result.as_ref() {
15631        Ok(NodeResponse::SessionRecordUpdated { record })
15632        | Ok(NodeResponse::ProviderSessionIndexed { record })
15633        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15634            !provider_id_is_legacy(&record.provider)
15635        }
15636        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15637            !provider_id_is_legacy(&selection.route.provider)
15638                || !provider_id_is_legacy(&record.provider)
15639        }
15640        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15641            !provider_id_is_legacy(&receipt.provider)
15642        }
15643        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15644            !provider_id_is_legacy(&receipt.spawn.provider)
15645        }
15646        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15647        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15648            !provider_id_is_legacy(&route.provider)
15649        }
15650        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15651            !provider_id_is_legacy(&selection.route.provider)
15652        }
15653        _ => false,
15654    };
15655    if contains_open_record {
15656        reply.result = Err(failure(
15657            NodeFailureCode::UnsupportedCapability,
15658            "open provider IDs were not negotiated",
15659        ));
15660        return;
15661    }
15662    let Ok(response) = reply.result.as_mut() else {
15663        return;
15664    };
15665    match response {
15666        NodeResponse::Snapshot { snapshot, .. } => {
15667            project_snapshot_legacy_provider_ids(snapshot);
15668        }
15669        NodeResponse::Resync { snapshot, events, .. } => {
15670            project_snapshot_legacy_provider_ids(snapshot);
15671            *events = std::mem::take(events)
15672                .into_iter()
15673                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15674                .collect();
15675        }
15676        NodeResponse::WorkspaceRegistered { workspace }
15677        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15678        | NodeResponse::WorktreeCreated { workspace, .. } => {
15679            workspace
15680                .sessions
15681                .retain(|session| provider_id_is_legacy(&session.agent_id));
15682        }
15683        NodeResponse::Armed { .. }
15684        | NodeResponse::Spawned { .. }
15685        | NodeResponse::Activated { .. }
15686        | NodeResponse::Aborted { .. }
15687        | NodeResponse::ReplyChunkAccepted { .. }
15688        | NodeResponse::CallRejected { .. }
15689        | NodeResponse::DeliveryStageBegun { .. }
15690        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15691        | NodeResponse::DeliveryCommitted { .. }
15692        | NodeResponse::DeliveryStageAborted { .. }
15693        | NodeResponse::WorkspaceInspected { .. }
15694        | NodeResponse::HostDirectoriesBrowsed { .. }
15695        | NodeResponse::WorkspaceFileRead { .. }
15696        | NodeResponse::WorkspaceFileWritten { .. }
15697        | NodeResponse::WorkspaceFileCreated { .. }
15698        | NodeResponse::WorkspaceDirectoryCreated { .. }
15699        | NodeResponse::GitHistoryRead { .. }
15700        | NodeResponse::GitDiffRead { .. }
15701        | NodeResponse::Controller { .. }
15702        | NodeResponse::SpawnAccepted { .. }
15703        | NodeResponse::SpawnSpecAccepted { .. }
15704        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15705        | NodeResponse::ManagedWorktreeCleanup { .. }
15706        | NodeResponse::NativeSessionsCataloged { .. }
15707        | NodeResponse::NativeSessionsPaged { .. }
15708        | NodeResponse::NativeSessionPreviewed { .. }
15709        | NodeResponse::SessionRecordPreviewed { .. }
15710        | NodeResponse::HistoryDiscovered { .. }
15711        | NodeResponse::HistoryLoaded { .. }
15712        | NodeResponse::ContextPackForSessionRecordExported { .. }
15713        | NodeResponse::ContextPackExported { .. }
15714        | NodeResponse::ContextPackForgotten { .. }
15715        | NodeResponse::DurableContextPackResolved { .. }
15716        | NodeResponse::ContextPackBytesRead { .. }
15717        | NodeResponse::SessionRecordUpdated { .. }
15718        | NodeResponse::ProviderSessionIndexed { .. }
15719        | NodeResponse::NativeSessionIndexed { .. }
15720        | NodeResponse::SessionRecordResumed { .. }
15721        | NodeResponse::SessionRecordForgotten { .. }
15722        | NodeResponse::WorkspaceUnregistered { .. }
15723        | NodeResponse::WorktreeRemoved { .. }
15724        | NodeResponse::Accepted
15725        | NodeResponse::ShuttingDown => {}
15726    }
15727}
15728
15729fn project_event_legacy_provider_ids(
15730    shared: &NodeShared,
15731    mut envelope: NodeEventEnvelope,
15732) -> Option<NodeEventEnvelope> {
15733    let include = match &mut envelope.event {
15734        NodeEvent::HarnessMcpReadCall { .. } => true,
15735        NodeEvent::Control { address, .. }
15736        | NodeEvent::AgentStream { address, .. }
15737        | NodeEvent::TerminalFrame { address, .. } => shared
15738            .validate_address(address)
15739            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15740        NodeEvent::WorkspaceAdded { workspace } => {
15741            workspace
15742                .sessions
15743                .retain(|session| provider_id_is_legacy(&session.agent_id));
15744            true
15745        }
15746        NodeEvent::SessionRecordUpserted { record } => {
15747            provider_id_is_legacy(&record.provider)
15748        }
15749        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15750        NodeEvent::SessionRecordRemoved { .. } => shared
15751            .history
15752            .lock()
15753            .unwrap_or_else(|poisoned| poisoned.into_inner())
15754            .removed_record_providers
15755            .get(&envelope.sequence)
15756            .is_some_and(provider_id_is_legacy),
15757        NodeEvent::ControllerChanged { .. }
15758        | NodeEvent::ManagedWorktreeUpserted { .. }
15759        | NodeEvent::ManagedWorktreeRemoved { .. }
15760        | NodeEvent::WorkspaceRemoved { .. }
15761        | NodeEvent::ResyncRequired { .. } => true,
15762    };
15763    include.then_some(envelope)
15764}
15765
15766fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15767    let Ok(response) = reply.result.as_mut() else {
15768        return;
15769    };
15770    match response {
15771        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15772            snapshot.provider_runtime_statuses.clear();
15773        }
15774        NodeResponse::Armed { .. }
15775        | NodeResponse::Spawned { .. }
15776        | NodeResponse::Activated { .. }
15777        | NodeResponse::Aborted { .. }
15778        | NodeResponse::ReplyChunkAccepted { .. }
15779        | NodeResponse::CallRejected { .. }
15780        | NodeResponse::DeliveryStageBegun { .. }
15781        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15782        | NodeResponse::DeliveryCommitted { .. }
15783        | NodeResponse::DeliveryStageAborted { .. }
15784        | NodeResponse::WorkspaceInspected { .. }
15785        | NodeResponse::HostDirectoriesBrowsed { .. }
15786        | NodeResponse::WorkspaceFileRead { .. }
15787        | NodeResponse::WorkspaceFileWritten { .. }
15788        | NodeResponse::WorkspaceFileCreated { .. }
15789        | NodeResponse::WorkspaceDirectoryCreated { .. }
15790        | NodeResponse::GitHistoryRead { .. }
15791        | NodeResponse::GitDiffRead { .. }
15792        | NodeResponse::Controller { .. }
15793        | NodeResponse::SpawnAccepted { .. }
15794        | NodeResponse::SpawnSpecAccepted { .. }
15795        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15796        | NodeResponse::ManagedWorktreeCleanup { .. }
15797        | NodeResponse::NativeSessionsCataloged { .. }
15798        | NodeResponse::NativeSessionsPaged { .. }
15799        | NodeResponse::NativeSessionPreviewed { .. }
15800        | NodeResponse::SessionRecordPreviewed { .. }
15801        | NodeResponse::HistoryDiscovered { .. }
15802        | NodeResponse::HistoryLoaded { .. }
15803        | NodeResponse::ContextPackForSessionRecordExported { .. }
15804        | NodeResponse::ContextPackExported { .. }
15805        | NodeResponse::ContextPackForgotten { .. }
15806        | NodeResponse::DurableContextPackResolved { .. }
15807        | NodeResponse::ContextPackBytesRead { .. }
15808        | NodeResponse::SessionRecordUpdated { .. }
15809        | NodeResponse::ProviderSessionIndexed { .. }
15810        | NodeResponse::NativeSessionIndexed { .. }
15811        | NodeResponse::SessionRecordResumed { .. }
15812        | NodeResponse::SessionRecordForgotten { .. }
15813        | NodeResponse::WorkspaceRegistered { .. }
15814        | NodeResponse::StandaloneWorkspaceCreated { .. }
15815        | NodeResponse::WorkspaceUnregistered { .. }
15816        | NodeResponse::WorktreeCreated { .. }
15817        | NodeResponse::WorktreeRemoved { .. }
15818        | NodeResponse::Accepted
15819        | NodeResponse::ShuttingDown => {}
15820    }
15821}
15822
15823struct ControllerReleaseGuard {
15824    shared: Arc<NodeShared>,
15825    connection_id: u64,
15826}
15827
15828impl Drop for ControllerReleaseGuard {
15829    fn drop(&mut self) {
15830        self.shared.release_controller(self.connection_id);
15831    }
15832}
15833
15834struct AbortTaskOnDrop(AbortHandle);
15835
15836impl Drop for AbortTaskOnDrop {
15837    fn drop(&mut self) {
15838        self.0.abort();
15839    }
15840}
15841
15842/// Reads an unsigned env-config override, following the pattern of every
15843/// other bounded knob in this module: missing/unparsable/zero falls back to
15844/// `default`, and the resolved value is always clamped to `max` so a bad
15845/// override cannot reopen a bound this budget exists to enforce.
15846fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15847    std::env::var(name)
15848        .ok()
15849        .and_then(|value| value.parse::<u64>().ok())
15850        .filter(|value| *value > 0)
15851        .unwrap_or(default)
15852        .min(max)
15853}
15854
15855fn workspace_inspection_time_budget_ms() -> u64 {
15856    env_config_bounded(
15857        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15858        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15859        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15860    )
15861}
15862
15863fn workspace_inspection_entry_cap() -> usize {
15864    env_config_bounded(
15865        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15866        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15867        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15868    ) as usize
15869}
15870
15871/// Tracks the shared walk-side budget across the whole recursive walk: a
15872/// wall-clock deadline (shared with the git phase that runs after the walk)
15873/// and a cap on directory entries visited, whether or not they were pushed
15874/// to the response. Cheap to check per entry — one `Instant::now()` and one
15875/// integer compare.
15876struct WorkspaceWalkBudget {
15877    deadline: Instant,
15878    entry_cap: usize,
15879    entries_visited: u64,
15880    time_budget_exceeded: bool,
15881    entry_cap_exceeded: bool,
15882}
15883
15884impl WorkspaceWalkBudget {
15885    fn new(deadline: Instant, entry_cap: usize) -> Self {
15886        Self {
15887            deadline,
15888            entry_cap,
15889            entries_visited: 0,
15890            time_budget_exceeded: false,
15891            entry_cap_exceeded: false,
15892        }
15893    }
15894
15895    fn is_exhausted(&self) -> bool {
15896        self.time_budget_exceeded || self.entry_cap_exceeded
15897    }
15898
15899    /// Records one visited directory entry (pushed to the response or not)
15900    /// and reports whether the walk must stop now.
15901    fn record_visit(&mut self) -> bool {
15902        self.entries_visited += 1;
15903        if self.entries_visited as usize >= self.entry_cap {
15904            self.entry_cap_exceeded = true;
15905        }
15906        if Instant::now() >= self.deadline {
15907            self.time_budget_exceeded = true;
15908        }
15909        self.is_exhausted()
15910    }
15911}
15912
15913fn collect_workspace_entries(
15914    root: &Path,
15915    deadline: Instant,
15916    entry_cap: usize,
15917) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15918    let mut entries = Vec::new();
15919    let mut truncated = false;
15920    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15921    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15922    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15923    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15924    // require strict global byte order over relative paths.
15925    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15926    (entries, truncated, budget)
15927}
15928
15929/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15930/// `inspect_workspace` response — `None` once the walk and git phase both
15931/// completed inside their shared budget.
15932fn workspace_inspection_truncation(
15933    walk_budget: &WorkspaceWalkBudget,
15934    git_time_budget_exceeded: bool,
15935    elapsed: Duration,
15936) -> Option<WorkspaceInspectionTruncationV1> {
15937    if !walk_budget.time_budget_exceeded
15938        && !walk_budget.entry_cap_exceeded
15939        && !git_time_budget_exceeded
15940    {
15941        return None;
15942    }
15943    Some(WorkspaceInspectionTruncationV1 {
15944        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15945        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15946        git_time_budget_exceeded,
15947        entries_visited: walk_budget.entries_visited,
15948        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15949    })
15950}
15951
15952fn walk_workspace_directory(
15953    directory: &Path,
15954    relative_directory: Option<&RepositoryPath>,
15955    depth: usize,
15956    entries: &mut Vec<WorkspaceEntry>,
15957    truncated: &mut bool,
15958    budget: &mut WorkspaceWalkBudget,
15959) {
15960    if budget.is_exhausted() {
15961        *truncated = true;
15962        return;
15963    }
15964    let read_dir = match std::fs::read_dir(directory) {
15965        Ok(read_dir) => read_dir,
15966        Err(_) => {
15967            *truncated = true;
15968            return;
15969        }
15970    };
15971    let mut children = Vec::new();
15972    for child in read_dir {
15973        match child {
15974            Ok(child) => children.push(child),
15975            Err(_) => *truncated = true,
15976        }
15977    }
15978    children.sort_by_key(|child| child.file_name());
15979    for child in children {
15980        if budget.record_visit() {
15981            *truncated = true;
15982            return;
15983        }
15984        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15985            *truncated = true;
15986            return;
15987        }
15988        let file_type = match child.file_type() {
15989            Ok(file_type) => file_type,
15990            Err(_) => {
15991                *truncated = true;
15992                continue;
15993            }
15994        };
15995        if file_type.is_symlink() {
15996            continue;
15997        }
15998        let name = match child.file_name().into_string() {
15999            Ok(name) => name,
16000            Err(_) => {
16001                *truncated = true;
16002                continue;
16003            }
16004        };
16005        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16006            continue;
16007        }
16008        let relative_path = match relative_directory {
16009            None => windows_repository_path(name),
16010            Some(parent) => parent
16011                .as_utf8()
16012                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16013        };
16014        let Some(relative_path) = relative_path else {
16015            *truncated = true;
16016            continue;
16017        };
16018        let kind = if file_type.is_dir() {
16019            WorkspaceEntryKind::Directory
16020        } else if file_type.is_file() {
16021            WorkspaceEntryKind::File
16022        } else {
16023            continue;
16024        };
16025        entries.push(WorkspaceEntry {
16026            relative_path: relative_path.clone(),
16027            kind,
16028        });
16029        if file_type.is_dir() {
16030            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16031                walk_workspace_directory(
16032                    &child.path(),
16033                    Some(&relative_path),
16034                    depth + 1,
16035                    entries,
16036                    truncated,
16037                    budget,
16038                );
16039            } else {
16040                *truncated = true;
16041            }
16042        }
16043    }
16044}
16045
16046fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16047    if value.contains('\\') {
16048        return None;
16049    }
16050    RepositoryPath::utf8(value).ok()
16051}
16052
16053fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16054    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16055}
16056
16057/// Excludes a directory from the workspace tree walk. The three literal
16058/// names are the common convention-based cases; the `CACHEDIR.TAG` check
16059/// beneath them catches every build-output directory regardless of name
16060/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
16061/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16062/// directory without maintaining a name heuristic list). The name check
16063/// runs first and returns early so a directory rejected by name never pays
16064/// for the extra `CACHEDIR.TAG` stat.
16065fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16066    if name.eq_ignore_ascii_case(".git")
16067        || name.eq_ignore_ascii_case("target")
16068        || name.eq_ignore_ascii_case("node_modules")
16069    {
16070        return true;
16071    }
16072    path.join("CACHEDIR.TAG").is_file()
16073}
16074
16075fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16076    matches!(
16077        status,
16078        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16079    )
16080}
16081
16082#[derive(Clone, Debug, Eq, PartialEq)]
16083enum ContextPackRepositoryHead {
16084    NotRepository,
16085    Unborn,
16086    Commit(String),
16087}
16088
16089#[derive(Clone, Debug, Eq, PartialEq)]
16090struct ContextPackRepositoryObservation {
16091    head: ContextPackRepositoryHead,
16092    git: GitSnapshot,
16093    files: Vec<ContextPackRepositoryFileSource>,
16094}
16095
16096fn stable_context_pack_repository(
16097    first: ContextPackRepositoryObservation,
16098    second: ContextPackRepositoryObservation,
16099) -> Result<ContextPackRepository, NodeFailure> {
16100    if first != second {
16101        return Err(failure(
16102            NodeFailureCode::ContextPackMaterializationFailed,
16103            "context repository changed during bounded capture",
16104        ));
16105    }
16106    Ok(ContextPackRepository::from_sources(
16107        &second.git,
16108        second.files,
16109    ))
16110}
16111
16112/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16113/// (and only once) the shared walk+git `deadline` has already passed. Every
16114/// remaining phase in `inspect_git_workspace` checks this immediately
16115/// before issuing its own git call, so a budget spent by the walk (or by an
16116/// earlier git call) skips the rest of the chain instead of paying for it
16117/// anyway.
16118fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16119    if Instant::now() < deadline {
16120        return false;
16121    }
16122    append_git_diagnostic(
16123        snapshot,
16124        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16125    );
16126    true
16127}
16128
16129/// Deadline-aware sibling of `run_git_bounded`, used only by
16130/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16131/// whatever remains of the shared `deadline` (never more than
16132/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16133/// of the budget cannot still hand a full per-call timeout to every git
16134/// probe behind it. `inspect_git_workspace` already guards each call site
16135/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16136/// passed yet here.
16137async fn run_git_bounded_with_deadline(
16138    root: &str,
16139    arguments: &[&str],
16140    output_limit: usize,
16141    deadline: Instant,
16142) -> io::Result<GitCommandOutput> {
16143    let remaining = deadline.checked_duration_since(Instant::now());
16144    let timeout_ms = remaining
16145        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16146        .unwrap_or(0)
16147        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16148    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16149}
16150
16151/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16152/// `deadline` is the same absolute instant the preceding directory walk was
16153/// bounded by, so time already spent walking counts against what remains
16154/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16155/// shared budget (not an individual per-call timeout) is what cut the git
16156/// phase short.
16157async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16158    let mut snapshot = GitSnapshot {
16159        is_repository: false,
16160        branch: None,
16161        status: Vec::new(),
16162        recent_commits: Vec::new(),
16163        worktrees: Vec::new(),
16164        managed_worktree: None,
16165        truncated: false,
16166        diagnostic: None,
16167    };
16168    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16169        return (snapshot, true);
16170    }
16171    let repository = match run_git_bounded_with_deadline(
16172        root,
16173        &["rev-parse", "--is-inside-work-tree"],
16174        4 * 1_024,
16175        deadline,
16176    )
16177    .await
16178    {
16179        Ok(output) => output,
16180        Err(error) => {
16181            append_git_diagnostic(
16182                &mut snapshot,
16183                &format!("git inspection unavailable: {error}"),
16184            );
16185            return (snapshot, false);
16186        }
16187    };
16188    snapshot.truncated |= repository.truncated;
16189    if repository.timed_out {
16190        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16191        return (snapshot, false);
16192    }
16193    if !repository.success
16194        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16195    {
16196        let stderr = String::from_utf8_lossy(&repository.stderr);
16197        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16198            append_git_diagnostic(&mut snapshot, stderr.trim());
16199        }
16200        return (snapshot, false);
16201    }
16202    snapshot.is_repository = true;
16203
16204    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16205        return (snapshot, true);
16206    }
16207    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16208        Ok(output) => {
16209            snapshot.truncated |= output.truncated;
16210            if output.timed_out {
16211                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16212            } else if output.success {
16213                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16214                if !branch.is_empty() {
16215                    snapshot.branch = Some(truncate_git_field(
16216                        &branch,
16217                        MAX_REPOSITORY_PATH_BYTES,
16218                        &mut snapshot.truncated,
16219                    ));
16220                }
16221            }
16222        }
16223        Err(error) => append_git_diagnostic(
16224            &mut snapshot,
16225            &format!("git branch query failed: {error}"),
16226        ),
16227    }
16228    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16229        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16230            snapshot.truncated |= output.truncated;
16231            if output.success && !output.timed_out {
16232                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16233                if !head.is_empty() {
16234                    snapshot.branch = Some(format!("detached:{head}"));
16235                }
16236            }
16237        }
16238    }
16239
16240    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16241        return (snapshot, true);
16242    }
16243    match run_git_bounded_with_deadline(
16244        root,
16245        &[
16246            "status",
16247            "--porcelain=v1",
16248            "-z",
16249            "--renames",
16250            "--untracked-files=normal",
16251            "--",
16252            ".",
16253        ],
16254        GIT_OUTPUT_MAX_BYTES,
16255        deadline,
16256    )
16257    .await
16258    {
16259        Ok(output) => {
16260            snapshot.truncated |= output.truncated;
16261            if output.timed_out {
16262                append_git_diagnostic(&mut snapshot, "git status timed out");
16263            } else if output.success {
16264                parse_git_status(&output.stdout, &mut snapshot);
16265            } else {
16266                append_git_diagnostic(
16267                    &mut snapshot,
16268                    String::from_utf8_lossy(&output.stderr).trim(),
16269                );
16270            }
16271        }
16272        Err(error) => append_git_diagnostic(
16273            &mut snapshot,
16274            &format!("git status failed: {error}"),
16275        ),
16276    }
16277
16278    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16279        return (snapshot, true);
16280    }
16281    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16282    match run_git_bounded_with_deadline(
16283        root,
16284        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16285        16 * 1_024,
16286        deadline,
16287    )
16288    .await
16289    {
16290        Ok(output) => {
16291            snapshot.truncated |= output.truncated;
16292            if output.timed_out {
16293                append_git_diagnostic(&mut snapshot, "git log timed out");
16294            } else if output.success {
16295                parse_git_commits(&output.stdout, &mut snapshot);
16296            } else {
16297                let stderr = String::from_utf8_lossy(&output.stderr);
16298                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16299                    append_git_diagnostic(&mut snapshot, stderr.trim());
16300                }
16301            }
16302        }
16303        Err(error) => append_git_diagnostic(
16304            &mut snapshot,
16305            &format!("git log failed: {error}"),
16306        ),
16307    }
16308
16309    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16310        return (snapshot, true);
16311    }
16312    match list_git_worktrees_with_deadline(root, deadline).await {
16313        Ok(worktrees) => {
16314            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16315        }
16316        Err(error) => append_git_diagnostic(
16317            &mut snapshot,
16318            &format!("git worktree list failed: {}", error.message),
16319        ),
16320    }
16321    (snapshot, false)
16322}
16323
16324fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16325    let mut remaining = output;
16326    while !remaining.is_empty() {
16327        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16328            snapshot.truncated = true;
16329            break;
16330        }
16331        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16332            snapshot.truncated = true;
16333            break;
16334        };
16335        let record = &remaining[..record_end];
16336        remaining = &remaining[record_end + 1..];
16337        let has_previous_path = record
16338            .get(..2)
16339            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16340        let previous_path_bytes = if has_previous_path {
16341            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16342                snapshot.truncated = true;
16343                break;
16344            };
16345            let previous = &remaining[..previous_end];
16346            remaining = &remaining[previous_end + 1..];
16347            Some(previous)
16348        } else {
16349            None
16350        };
16351        if record.len() < 4
16352            || record[2] != b' '
16353            || !is_git_status_code(record[0])
16354            || !is_git_status_code(record[1])
16355        {
16356            snapshot.truncated = true;
16357            continue;
16358        }
16359        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16360            snapshot.truncated = true;
16361            continue;
16362        };
16363        let previous_path = match previous_path_bytes {
16364            Some(previous) => {
16365                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16366                    snapshot.truncated = true;
16367                    continue;
16368                };
16369                Some(previous)
16370            }
16371            None => None,
16372        };
16373        snapshot.status.push(GitStatusEntry {
16374            index_status: (record[0] as char).to_string(),
16375            worktree_status: (record[1] as char).to_string(),
16376            path,
16377            previous_path,
16378        });
16379    }
16380}
16381
16382fn is_git_status_code(byte: u8) -> bool {
16383    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16384}
16385
16386fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16387    for line in String::from_utf8_lossy(output).lines() {
16388        let Some((id, summary)) = line.split_once('\u{1f}') else {
16389            continue;
16390        };
16391        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16392            snapshot.truncated = true;
16393            break;
16394        }
16395        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16396        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16397        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16398    }
16399}
16400
16401fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16402    if value.len() <= max_bytes {
16403        return value.to_owned();
16404    }
16405    *truncated = true;
16406    let mut boundary = max_bytes;
16407    while boundary > 0 && !value.is_char_boundary(boundary) {
16408        boundary -= 1;
16409    }
16410    value[..boundary].to_owned()
16411}
16412
16413fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16414    if message.is_empty() {
16415        return;
16416    }
16417    let normalized = message
16418        .chars()
16419        .map(|character| if character.is_control() { ' ' } else { character })
16420        .collect::<String>();
16421    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16422    if !diagnostic.is_empty() {
16423        diagnostic.push_str("; ");
16424    }
16425    diagnostic.push_str(normalized.trim());
16426    snapshot.diagnostic = Some(truncate_git_field(
16427        &diagnostic,
16428        GIT_DIAGNOSTIC_MAX_BYTES,
16429        &mut snapshot.truncated,
16430    ));
16431}
16432
16433async fn run_git_bounded(
16434    root: &str,
16435    arguments: &[&str],
16436    output_limit: usize,
16437) -> io::Result<GitCommandOutput> {
16438    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16439}
16440
16441async fn read_git_history_bounded(
16442    root: &str,
16443    path: Option<&RepositoryPath>,
16444    before: Option<&GitObjectId>,
16445    limit: u16,
16446) -> Result<GitHistoryPage, NodeFailure> {
16447    let path = path
16448        .map(|path| {
16449            path.as_utf8().ok_or_else(|| {
16450                failure(
16451                    NodeFailureCode::InvalidRepositoryPath,
16452                    "git history path must use UTF-8",
16453                )
16454            })
16455        })
16456        .transpose()?
16457        .unwrap_or(".");
16458    let count = (usize::from(limit) + 1).to_string();
16459    let start = before
16460        .map(|revision| format!("{}^", revision.as_str()))
16461        .unwrap_or_else(|| "HEAD".to_owned());
16462    let arguments = [
16463        "--no-pager",
16464        "log",
16465        "--no-decorate",
16466        "--date=iso-strict",
16467        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16468        "-n",
16469        count.as_str(),
16470        start.as_str(),
16471        "--",
16472        path,
16473    ];
16474    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16475        .await
16476        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16477    if output.timed_out {
16478        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16479    }
16480    if !output.success {
16481        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16482    }
16483    let mut commits = parse_git_history(&output.stdout);
16484    let has_more = commits.len() > usize::from(limit) || output.truncated;
16485    commits.truncate(usize::from(limit));
16486    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16487    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16488}
16489
16490fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16491    String::from_utf8_lossy(output)
16492        .split('\u{1e}')
16493        .filter_map(|record| {
16494            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16495            if fields.len() != 9 {
16496                return None;
16497            }
16498            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16499            let parents = if fields[1].is_empty() {
16500                Vec::new()
16501            } else {
16502                fields[1]
16503                    .split(' ')
16504                    .map(|value| GitObjectId::new(value.to_owned()))
16505                    .collect::<Result<Vec<_>, _>>()
16506                    .ok()?
16507            };
16508            Some(GitCommitDetails {
16509                id,
16510                parents,
16511                subject: bounded_git_text(fields[2], 512),
16512                author_name: bounded_git_text(fields[3], 256),
16513                author_email: bounded_git_text(fields[4], 320),
16514                authored_at: bounded_git_text(fields[5], 64),
16515                committer_name: bounded_git_text(fields[6], 256),
16516                committer_email: bounded_git_text(fields[7], 320),
16517                committed_at: bounded_git_text(fields[8], 64),
16518                signature_status: GitSignatureStatus::NoSignature,
16519                signer: None,
16520            })
16521        })
16522        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16523        .collect()
16524}
16525
16526fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16527    let normalized = value
16528        .chars()
16529        .map(|character| if character.is_control() { ' ' } else { character })
16530        .collect::<String>();
16531    if normalized.len() <= max_bytes {
16532        return normalized;
16533    }
16534    let mut boundary = max_bytes;
16535    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16536        boundary -= 1;
16537    }
16538    normalized[..boundary].to_owned()
16539}
16540
16541async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16542    let path = request.path.as_ref().map(|path| {
16543        path.as_utf8()
16544            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16545    }).transpose()?;
16546    let mut arguments = match &request.mode {
16547        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16548        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16549        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16550    };
16551    if let GitDiffMode::Commit { revision } = &request.mode {
16552        arguments.push(revision.as_str());
16553    }
16554    arguments.push("--");
16555    if let Some(path) = path {
16556        arguments.push(path);
16557    }
16558    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16559        .await
16560        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16561    if output.timed_out {
16562        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16563    }
16564    if !output.success {
16565        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16566    }
16567    Ok(GitDiff {
16568        mode: request.mode,
16569        path: request.path,
16570        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16571        truncated: output.truncated,
16572    })
16573}
16574
16575async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16576    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16577    ResponseEnvelope { request_id: envelope.request_id, result }
16578}
16579
16580async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16581    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16582        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16583    }
16584    let read_only = matches!(
16585        &request,
16586        NodeRequest::Snapshot
16587            | NodeRequest::Resync { .. }
16588            | NodeRequest::BrowseHostDirectories { .. }
16589            | NodeRequest::InspectWorkspace { .. }
16590            | NodeRequest::ReadWorkspaceFile { .. }
16591            | NodeRequest::ReadGitHistory { .. }
16592            | NodeRequest::ReadGitDiff { .. }
16593            | NodeRequest::CatalogNativeSessions { .. }
16594            | NodeRequest::PageNativeSessions { .. }
16595            | NodeRequest::PreviewNativeSession { .. }
16596            | NodeRequest::PreviewSessionRecord { .. }
16597    );
16598    let _mutation_guard = if read_only {
16599        None
16600    } else {
16601        let guard = shared.mutation_gate.lock().await;
16602        if shared.shutdown.load(Ordering::Acquire) {
16603            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16604        }
16605        Some(guard)
16606    };
16607    match request {
16608        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16609            event_sequence: shared.current_sequence(),
16610            controller: shared.controller_state(),
16611            snapshot: shared.snapshot(),
16612        }),
16613        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16614        NodeRequest::ArmHarnessMcpReservation {
16615            reservation_id,
16616            activation_digest,
16617            spawn_spec,
16618            launch,
16619            expires_at_unix_ms,
16620        } => {
16621            shared.require_controller(connection_id, role)?;
16622            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16623                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16624            })?;
16625            let expires_at_unix_ms = registry
16626                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16627                .await
16628                .map_err(harness_mcp_failure)?;
16629            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16630        }
16631        NodeRequest::SpawnSpecWithHarnessMcp {
16632            reservation_id,
16633            activation_digest,
16634            spec,
16635            deadline_unix_ms,
16636        } => {
16637            shared.require_controller(connection_id, role)?;
16638            let receipt = shared
16639                .spawn_from_spec_with_harness_mcp(
16640                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16641                )
16642                .await?;
16643            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16644        }
16645        NodeRequest::ActivateHarnessMcpReservation {
16646            reservation_id,
16647            activation_digest,
16648            record_id,
16649            session,
16650        } => {
16651            shared.require_controller(connection_id, role)?;
16652            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16653                current.instance_id == session.session.instance_id
16654                    && current.generation == session.session.generation
16655            }).and_then(|current| current.process_id).ok_or_else(|| {
16656                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16657            })?;
16658            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16659                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16660            })?.activate(
16661                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16662            ).map_err(harness_mcp_failure)?;
16663            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16664        }
16665        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16666            shared.require_controller(connection_id, role)?;
16667            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16668                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16669            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16670            if let Some(instance_id) = instance_id {
16671                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16672            }
16673            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16674        }
16675        NodeRequest::PutHarnessMcpReplyChunk {
16676            reservation_id, activation_digest, record_id, session, call_id,
16677            offset, final_chunk, chunk_hex,
16678        } => {
16679            shared.require_controller(connection_id, role)?;
16680            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16681                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16682            })?.put_reply_chunk(
16683                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16684                offset, final_chunk, &chunk_hex,
16685            ).map_err(harness_mcp_failure)?;
16686            Ok(NodeResponse::ReplyChunkAccepted {
16687                reservation_id, activation_digest, record_id, session, call_id,
16688                next_offset, completed,
16689            })
16690        }
16691        NodeRequest::RejectHarnessMcpCall {
16692            reservation_id, activation_digest, record_id, session, call_id, reason,
16693        } => {
16694            shared.require_controller(connection_id, role)?;
16695            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16696                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16697            })?.reject_call(
16698                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16699            ).map_err(harness_mcp_failure)?;
16700            Ok(NodeResponse::CallRejected {
16701                reservation_id, activation_digest, record_id, session, call_id,
16702            })
16703        }
16704        NodeRequest::BeginDeliveryStage { manifest } => {
16705            shared.require_controller(connection_id, role)?;
16706            shared.begin_delivery_stage(manifest)
16707        }
16708        NodeRequest::PutDeliveryBlobChunk {
16709            stage_id,
16710            blob_digest,
16711            offset,
16712            chunk_hex,
16713        } => {
16714            shared.require_controller(connection_id, role)?;
16715            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16716        }
16717        NodeRequest::CommitDeliveryStage { stage_id } => {
16718            shared.require_controller(connection_id, role)?;
16719            let receipt = shared.commit_delivery_stage(stage_id)?;
16720            Ok(NodeResponse::DeliveryCommitted { receipt })
16721        }
16722        NodeRequest::AbortDeliveryStage { stage_id } => {
16723            shared.require_controller(connection_id, role)?;
16724            shared.abort_delivery_stage(&stage_id)?;
16725            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16726        }
16727        NodeRequest::BrowseHostDirectories { directory, after } => {
16728            let listing = shared.browse_host_directories(directory, after).await?;
16729            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16730        }
16731        NodeRequest::InspectWorkspace { workspace_id } => {
16732            let inspection = shared.inspect_workspace(workspace_id).await?;
16733            Ok(NodeResponse::WorkspaceInspected { inspection })
16734        }
16735        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16736            let file = shared.read_workspace_file(workspace_id, path).await?;
16737            Ok(NodeResponse::WorkspaceFileRead { file })
16738        }
16739        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16740            shared.require_controller(connection_id, role)?;
16741            let file = shared
16742                .write_workspace_file(workspace_id, path, expected_revision, text)
16743                .await?;
16744            Ok(NodeResponse::WorkspaceFileWritten { file })
16745        }
16746        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16747            shared.require_controller(connection_id, role)?;
16748            let file = shared.create_workspace_file(workspace_id, path).await?;
16749            Ok(NodeResponse::WorkspaceFileCreated { file })
16750        }
16751        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16752            shared.require_controller(connection_id, role)?;
16753            let entry = shared
16754                .create_workspace_directory(workspace_id.clone(), path)
16755                .await?;
16756            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16757        }
16758        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16759            if role != ClientRole::Operator {
16760                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16761            }
16762            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16763                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16764            }
16765            let page = shared
16766                .read_git_history(workspace_id.clone(), path, before, limit)
16767                .await?;
16768            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16769        }
16770        NodeRequest::ReadGitDiff { workspace_id, request } => {
16771            if role != ClientRole::Operator {
16772                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16773            }
16774            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16775            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16776        }
16777        NodeRequest::CatalogNativeSessions { route, limit } => {
16778            if role != ClientRole::Operator {
16779                return Err(failure(
16780                    NodeFailureCode::ObserverReadOnly,
16781                    "native session catalog requires an operator connection",
16782                ));
16783            }
16784            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16785                return Err(failure(
16786                    NodeFailureCode::InvalidRequest,
16787                    "native session catalog limit is invalid",
16788                ));
16789            }
16790            let (entries, summary) = shared
16791                .catalog_native_sessions(route.clone(), limit)
16792                .await?;
16793            Ok(NodeResponse::NativeSessionsCataloged {
16794                route,
16795                entries,
16796                summary: Some(summary),
16797            })
16798        }
16799        NodeRequest::PageNativeSessions {
16800            route,
16801            window,
16802            catalog_revision,
16803            recent_cutoff_unix_ms,
16804            after_selection_id,
16805            limit,
16806        } => {
16807            if role != ClientRole::Operator {
16808                return Err(failure(
16809                    NodeFailureCode::ObserverReadOnly,
16810                    "native session catalog paging requires an operator connection",
16811                ));
16812            }
16813            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16814                || after_selection_id
16815                    .as_deref()
16816                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16817            {
16818                return Err(failure(
16819                    NodeFailureCode::InvalidRequest,
16820                    "native session catalog page request is invalid",
16821                ));
16822            }
16823            let page = shared
16824                .page_native_sessions(
16825                    route.clone(),
16826                    window,
16827                    catalog_revision,
16828                    recent_cutoff_unix_ms,
16829                    after_selection_id,
16830                    limit,
16831                )
16832                .await?;
16833            Ok(NodeResponse::NativeSessionsPaged {
16834                route,
16835                page,
16836            })
16837        }
16838        NodeRequest::PreviewNativeSession {
16839            selection,
16840            message_limit,
16841        } => {
16842            if role != ClientRole::Operator {
16843                return Err(failure(
16844                    NodeFailureCode::ObserverReadOnly,
16845                    "native session preview requires an operator connection",
16846                ));
16847            }
16848            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16849                .contains(&message_limit)
16850                || selection.validate().is_err()
16851            {
16852                return Err(failure(
16853                    NodeFailureCode::InvalidRequest,
16854                    "native session preview request is invalid",
16855                ));
16856            }
16857            let preview = shared
16858                .preview_native_session(selection.clone(), message_limit)
16859                .await?;
16860            Ok(NodeResponse::NativeSessionPreviewed {
16861                selection,
16862                preview,
16863            })
16864        }
16865        NodeRequest::IndexNativeSession {
16866            selection,
16867            display_name,
16868        } => {
16869            shared.require_controller(connection_id, role)?;
16870            let record = shared
16871                .index_native_session(selection.clone(), display_name)
16872                .await?;
16873            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16874        }
16875        NodeRequest::PreviewSessionRecord {
16876            record_id,
16877            message_limit,
16878        } => {
16879            if role != ClientRole::Operator {
16880                return Err(failure(
16881                    NodeFailureCode::ObserverReadOnly,
16882                    "native session preview requires an operator connection",
16883                ));
16884            }
16885            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16886                .contains(&message_limit)
16887            {
16888                return Err(failure(
16889                    NodeFailureCode::InvalidRequest,
16890                    "native session preview request is invalid",
16891                ));
16892            }
16893            let record = shared.record(&record_id)?;
16894            let working_directory = shared.workspace_root(&record.workspace_id)?;
16895            if !platform::roots_equal(
16896                &working_directory,
16897                windows_path_text(&record.canonical_root),
16898            ) {
16899                return Err(failure(
16900                    NodeFailureCode::SessionRecordConflict,
16901                    "managed session workspace root changed; refusing to preview another directory",
16902                ));
16903            }
16904            let identity = record.provider_session.clone().ok_or_else(|| {
16905                failure(
16906                    NodeFailureCode::BackendOperationFailed,
16907                    "native session preview is unavailable for this session record",
16908                )
16909            })?;
16910            if identity.key != ProviderSessionKey::SessionId {
16911                return Err(failure(
16912                    NodeFailureCode::BackendOperationFailed,
16913                    "native session preview is unavailable for this session identity",
16914                ));
16915            }
16916            let preview = shared
16917                .preview_native_session_by_session_id(
16918                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16919                )
16920                .await?;
16921            if preview.session_id != identity.id {
16922                return Err(failure(
16923                    NodeFailureCode::SessionRecordConflict,
16924                    "native session preview identity changed while loading",
16925                ));
16926            }
16927            shared.revalidate_session_record_preview(&record, &identity)?;
16928            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16929            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16930                record_id: record_id.clone(),
16931                summary: SessionHistorySummaryV1::from(&preview),
16932            });
16933            Ok(NodeResponse::SessionRecordPreviewed {
16934                record_id,
16935                preview,
16936            })
16937        }
16938        NodeRequest::AcquireController { lease_ms } => {
16939            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16940            Ok(NodeResponse::Controller { controller: Some(controller) })
16941        }
16942        NodeRequest::ReleaseController => {
16943            shared.release_controller(connection_id);
16944            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16945        }
16946        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16947            shared.require_controller(connection_id, role)?;
16948            let root = require_windows_path(root)?;
16949            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16950            let workspace = shared.register_workspace(workspace_id, root).await?;
16951            Ok(NodeResponse::WorkspaceRegistered { workspace })
16952        }
16953        NodeRequest::CreateStandaloneWorkspace {
16954            workspace_id,
16955            root,
16956            initial_branch,
16957        } => {
16958            shared.require_controller(connection_id, role)?;
16959            let root = require_windows_path(root)?;
16960            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16961            let workspace = shared
16962                .create_standalone_workspace(workspace_id, root, initial_branch)
16963                .await?;
16964            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16965        }
16966        NodeRequest::UnregisterWorkspace { workspace_id } => {
16967            shared.require_controller(connection_id, role)?;
16968            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16969            shared.unregister_workspace(&workspace_id)?;
16970            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16971        }
16972        NodeRequest::CreateWorktree {
16973            source_workspace_id,
16974            workspace_id,
16975            target_root,
16976            branch,
16977            base,
16978        } => {
16979            shared.require_controller(connection_id, role)?;
16980            let target_root = require_windows_path(target_root)?;
16981            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16982            let (worktree, workspace) = shared
16983                .create_worktree(
16984                    source_workspace_id,
16985                    workspace_id,
16986                    target_root,
16987                    branch,
16988                    base,
16989                )
16990                .await?;
16991            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16992        }
16993        NodeRequest::RemoveWorktree {
16994            source_workspace_id,
16995            target_root,
16996        } => {
16997            shared.require_controller(connection_id, role)?;
16998            let native_target_root = require_windows_path(target_root.clone())?;
16999            shared.reject_managed_reservation(None, Some(&native_target_root))?;
17000            let workspace_id = shared
17001                .remove_worktree(source_workspace_id, native_target_root)
17002                .await?;
17003            Ok(NodeResponse::WorktreeRemoved {
17004                target_root,
17005                workspace_id,
17006            })
17007        }
17008        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17009            shared.require_controller(connection_id, role)?;
17010            if !terminal_size.is_valid() {
17011                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17012            }
17013            if let Some(prompt) = initial_prompt.as_deref() {
17014                validate_node_text("spawn initial prompt", prompt)?;
17015            }
17016            match shared
17017                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17018                .await
17019            {
17020                Ok(session) => {
17021                    shared.mark_spawn_provisional(session.clone());
17022                    Ok(NodeResponse::SpawnAccepted { session })
17023                }
17024                Err(err) => {
17025                    tracing::warn!(
17026                        %provider,
17027                        %workspace_id,
17028                        ?mode,
17029                        code = ?err.code,
17030                        message = %err.message,
17031                        "session spawn refused"
17032                    );
17033                    Err(err)
17034                }
17035            }
17036        }
17037        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17038            shared.require_controller(connection_id, role)?;
17039            if !terminal_size.is_valid() {
17040                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17041            }
17042            if let Some(prompt) = initial_prompt.as_deref() {
17043                validate_node_text("spawn initial prompt", prompt)?;
17044            }
17045            let plan = backend_paths::plan_launch_options(
17046                provider.as_str(),
17047                mode,
17048                options.model.is_some(),
17049                options.effort.is_some(),
17050            )
17051            .map_err(|r| {
17052                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17053                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17054            })?;
17055            let selection = spawn_selection(&options)?;
17056            let nick_model = selection.as_ref().map(|s| s.model.clone());
17057            let pty_selection = match plan {
17058                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17059                _ => None,
17060            };
17061            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17062                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17063                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17064                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17065                        .await
17066                }).await
17067            }).await;
17068            match spawned {
17069                Ok(session) => {
17070                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17071                        shared
17072                            .pending_acp_options
17073                            .lock()
17074                            .unwrap_or_else(|p| p.into_inner())
17075                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17076                    }
17077                    shared.mark_spawn_provisional(session.clone());
17078                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17079                    Ok(NodeResponse::SpawnAccepted { session })
17080                }
17081                Err(err) => {
17082                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17083                    Err(err)
17084                }
17085            }
17086        }
17087        NodeRequest::SpawnSpec { spec } => {
17088            shared.require_controller(connection_id, role)?;
17089            let receipt = shared.spawn_from_spec(spec).await?;
17090            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17091        }
17092        NodeRequest::SpawnManagedWorktree { request } => {
17093            shared.require_controller(connection_id, role)?;
17094            let receipt = shared.spawn_managed_worktree(request).await?;
17095            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17096        }
17097        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17098            shared.require_controller(connection_id, role)?;
17099            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17100            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17101        }
17102        NodeRequest::CleanupManagedWorktree { lease_id } => {
17103            shared.require_controller(connection_id, role)?;
17104            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17105            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17106        }
17107        NodeRequest::DiscoverHistory { session, limit } => {
17108            controlled_session(shared, connection_id, role, &session)?;
17109            let candidates = shared.discover_history(&session, limit).await?;
17110            Ok(NodeResponse::HistoryDiscovered {
17111                session,
17112                candidates,
17113            })
17114        }
17115        NodeRequest::LoadHistory {
17116            session,
17117            candidate_id,
17118        } => {
17119            controlled_session(shared, connection_id, role, &session)?;
17120            let loaded = shared
17121                .load_history(&session, candidate_id)
17122                .await?;
17123            Ok(NodeResponse::HistoryLoaded {
17124                session,
17125                session_id: loaded.session_id,
17126                message_count: loaded.message_count,
17127                completed_turn_count: loaded.completed_turn_count,
17128            })
17129        }
17130        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17131            shared.require_controller(connection_id, role)?;
17132            let context = shared
17133                .export_context_pack_for_session_record(&record_id, &session, false)
17134                .await?;
17135            Ok(NodeResponse::ContextPackForSessionRecordExported {
17136                record_id,
17137                session,
17138                context,
17139            })
17140        }
17141        NodeRequest::ExportContextPack { session } => {
17142            controlled_session(shared, connection_id, role, &session)?;
17143            let context = shared.export_context_pack(&session).await?;
17144            Ok(NodeResponse::ContextPackExported { context })
17145        }
17146        NodeRequest::ForgetContextPack { context_id } => {
17147            shared.require_controller(connection_id, role)?;
17148            shared.forget_context_pack(&context_id)?;
17149            Ok(NodeResponse::ContextPackForgotten { context_id })
17150        }
17151        NodeRequest::ResolveDurableContextPack { context_id } => {
17152            // Read-only, idempotent, no controller lease required: this lets
17153            // a caller preflight "is this durably exported pack still here"
17154            // without holding the Node's mutation controller.
17155            let context = shared
17156                .context_catalog
17157                .read()
17158                .unwrap_or_else(|poisoned| poisoned.into_inner())
17159                .get(&context_id)
17160                .map(|pack| pack.receipt().clone())
17161                .ok_or_else(|| {
17162                    failure(
17163                        NodeFailureCode::UnknownContextPack,
17164                        "context pack is not present",
17165                    )
17166                })?;
17167            Ok(NodeResponse::DurableContextPackResolved { context })
17168        }
17169        NodeRequest::ReadContextPack { digest } => {
17170            // Read-only fetch of the pack's own bytes, keyed by content digest
17171            // rather than the caller's `SpawnContextId`: same no-controller-lease
17172            // rationale as `ResolveDurableContextPack` above, one step further
17173            // down the same catalog.
17174            let (id, byte_len, bytes) = shared
17175                .context_catalog
17176                .read()
17177                .unwrap_or_else(|poisoned| poisoned.into_inner())
17178                .get_by_digest(&digest)
17179                .map(|pack| {
17180                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17181                })
17182                .ok_or_else(|| {
17183                    failure(
17184                        NodeFailureCode::UnknownContextPack,
17185                        &format!("context pack {digest} not on this node"),
17186                    )
17187                })?;
17188            tracing::debug!(
17189                digest = digest.as_str(),
17190                byte_len,
17191                "read context pack bytes",
17192            );
17193            Ok(NodeResponse::ContextPackBytesRead {
17194                pack: ContextPackBytesRead {
17195                    digest,
17196                    id,
17197                    byte_len,
17198                    bytes,
17199                },
17200            })
17201        }
17202        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17203            let provider = controlled_session(shared, connection_id, role, &session)?;
17204            if let Some(prompt) = initial_prompt.as_deref() {
17205                validate_node_text("resume initial prompt", prompt)?;
17206            }
17207            let runtime_requirement = if initial_prompt.is_some() {
17208                ProviderRuntimeRequirement::ResumeWithPrompt
17209            } else {
17210                ProviderRuntimeRequirement::Resume
17211            };
17212            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17213            let runtime_policy = shared
17214                .admit_provider_runtime(&provider, runtime_requirement)
17215                .await?;
17216            let working_directory = shared.bound_workspace_root(&session)?;
17217            let request = ResumeLaunchRequest {
17218                working_directory,
17219                terminal_size,
17220                initial_prompt,
17221            };
17222            request
17223                .validate()
17224                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17225            let command = shared.prepare_command(ControlCommand::Resume {
17226                instance_id: session.session.instance_id,
17227                target: ResumeTarget::CurrentProvider,
17228                runtime_policy,
17229                request,
17230            });
17231            shared.arm_resume(&session, command.id, runtime_policy)?;
17232            let dispatch = shared.dispatch_envelope(command);
17233            if let Err(error) = dispatch {
17234                shared.clear_armed_resume(&session);
17235                return Err(error);
17236            }
17237            Ok(NodeResponse::Accepted)
17238        }
17239        NodeRequest::RenameSessionRecord {
17240            record_id,
17241            display_name,
17242        } => {
17243            shared.require_controller(connection_id, role)?;
17244            let record = shared.rename_session_record(&record_id, display_name)?;
17245            Ok(NodeResponse::SessionRecordUpdated { record })
17246        }
17247        NodeRequest::SetSessionTask {
17248            record_id,
17249            expected_revision,
17250            target,
17251        } => {
17252            shared.require_controller(connection_id, role)?;
17253            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17254            Ok(NodeResponse::SessionRecordUpdated { record })
17255        }
17256        NodeRequest::IndexProviderSession {
17257            workspace_id,
17258            provider,
17259            identity,
17260            display_name,
17261        } => {
17262            shared.require_controller(connection_id, role)?;
17263            let record = shared.index_provider_session(
17264                workspace_id,
17265                provider,
17266                identity,
17267                display_name,
17268            )?;
17269            Ok(NodeResponse::ProviderSessionIndexed { record })
17270        }
17271        NodeRequest::ResumeSessionRecord {
17272            record_id,
17273            terminal_size,
17274            initial_prompt,
17275        } => {
17276            shared.require_controller(connection_id, role)?;
17277            if let Some(prompt) = initial_prompt.as_deref() {
17278                validate_node_text("managed resume initial prompt", prompt)?;
17279            }
17280            let (record, session) = shared
17281                .resume_session_record(
17282                    &record_id,
17283                    terminal_size,
17284                    initial_prompt,
17285                )
17286                .await?;
17287            Ok(NodeResponse::SessionRecordResumed { record, session })
17288        }
17289        NodeRequest::ForgetSessionRecord { record_id } => {
17290            shared.require_controller(connection_id, role)?;
17291            shared.forget_session_record(&record_id).await?;
17292            Ok(NodeResponse::SessionRecordForgotten { record_id })
17293        }
17294        NodeRequest::Prompt { session, text } => {
17295            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17296            validate_node_text("prompt", &text)?;
17297            shared.require_session_runtime_policy(
17298                &session,
17299                ProviderRuntimeRequirement::SemanticPrompt,
17300            )?;
17301            let framing = prompt_framing(&agent_id);
17302            shared
17303                .dispatch_input_bounded(
17304                    &session,
17305                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17306                )
17307                .await?;
17308            Ok(NodeResponse::Accepted)
17309        }
17310        NodeRequest::Paste { session, text } => {
17311            controlled_session(shared, connection_id, role, &session)?;
17312            validate_node_text("paste", &text)?;
17313            shared.require_session_runtime_policy(
17314                &session,
17315                ProviderRuntimeRequirement::SemanticPrompt,
17316            )?;
17317            shared
17318                .dispatch_input_bounded(
17319                    &session,
17320                    InputAction::InsertDraft(PromptPayload {
17321                        text,
17322                        framing: PromptFraming::BracketedPaste,
17323                    }),
17324                )
17325                .await?;
17326            Ok(NodeResponse::Accepted)
17327        }
17328        NodeRequest::Input { session, text } => {
17329            controlled_session(shared, connection_id, role, &session)?;
17330            validate_node_text("terminal input", &text)?;
17331            shared
17332                .dispatch_input_bounded(
17333                    &session,
17334                    InputAction::TerminalText(TerminalText { text }),
17335                )
17336                .await?;
17337            Ok(NodeResponse::Accepted)
17338        }
17339        NodeRequest::TerminalBytes { session, bytes } => {
17340            controlled_session(shared, connection_id, role, &session)?;
17341            shared.sessionize_spawn(session.session.instance_id);
17342            let action = terminal_bytes_action(bytes)?;
17343            shared
17344                .dispatch_input_bounded(&session, action)
17345                .await?;
17346            Ok(NodeResponse::Accepted)
17347        }
17348        NodeRequest::TerminalControl { session, control } => {
17349            controlled_session(shared, connection_id, role, &session)?;
17350            shared
17351                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17352                .await?;
17353            Ok(NodeResponse::Accepted)
17354        }
17355        NodeRequest::Resize { session, size } => {
17356            controlled_session(shared, connection_id, role, &session)?;
17357            shared.sessionize_spawn(session.session.instance_id);
17358            if !size.is_valid() {
17359                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17360            }
17361            shared.dispatch_resize_bounded(&session, size).await?;
17362            Ok(NodeResponse::Accepted)
17363        }
17364        NodeRequest::Interrupt { session } => {
17365            controlled_session(shared, connection_id, role, &session)?;
17366            shared
17367                .dispatch_input_bounded(
17368                    &session,
17369                    InputAction::TerminalControl(TerminalControl::Interrupt),
17370                )
17371                .await?;
17372            Ok(NodeResponse::Accepted)
17373        }
17374        NodeRequest::Stop { session, force } => {
17375            controlled_session(shared, connection_id, role, &session)?;
17376            shared.sessionize_spawn(session.session.instance_id);
17377            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17378            shared.retire_session_grok_leader(session.session.instance_id);
17379            Ok(NodeResponse::Accepted)
17380        }
17381        NodeRequest::Remove { session } => {
17382            controlled_session(shared, connection_id, role, &session)?;
17383            shared.remove_session(&session).await?;
17384            Ok(NodeResponse::Accepted)
17385        }
17386        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17387            shared.require_controller(connection_id, role)?;
17388            let snapshot = shared.require_acp_session(&session)?;
17389            let interaction = snapshot
17390                .provider
17391                .interactions
17392                .iter()
17393                .find(|interaction| {
17394                    interaction_correlation(
17395                        session.session.instance_id,
17396                        session.session.generation,
17397                        interaction.id.0,
17398                    ) == correlation_id
17399                })
17400                .ok_or_else(|| failure(
17401                    NodeFailureCode::InvalidRequest,
17402                    "unknown interaction correlation",
17403                ))?;
17404            if interaction.status != ProviderInteractionStatus::Pending {
17405                return Err(failure(
17406                    NodeFailureCode::BackendOperationFailed,
17407                    "interaction has already been resolved",
17408                ));
17409            }
17410            response
17411                .validate_for(interaction.interaction_kind)
17412                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17413            let interaction_id = interaction.id;
17414            let response_kind = response.kind();
17415            let response_outcome = response.outcome();
17416            shared.dispatch(ControlCommand::ResolveInteraction {
17417                instance_id: session.session.instance_id,
17418                generation: session.session.generation,
17419                interaction_id,
17420                response,
17421            })?;
17422            // The one line that lets an operator compare what THIS node
17423            // recorded against what the provider itself reports doing --
17424            // measured live on a kimi run where our side resolved `true`
17425            // while the agent reported "rejected" and neither layer had a
17426            // line to line up against the other.
17427            tracing::info!(
17428                node_id = %shared.node_id,
17429                session = ?session.session,
17430                correlation_id = %correlation_id,
17431                interaction_id = interaction_id.0,
17432                response_kind = ?response_kind,
17433                outcome = ?response_outcome,
17434                "interaction resolved",
17435            );
17436            Ok(NodeResponse::Accepted)
17437        }
17438        NodeRequest::SetSessionMode { session, mode_id } => {
17439            shared.require_controller(connection_id, role)?;
17440            shared.require_acp_session(&session)?;
17441            shared.dispatch(ControlCommand::SetSessionMode {
17442                instance_id: session.session.instance_id,
17443                mode_id,
17444            })?;
17445            Ok(NodeResponse::Accepted)
17446        }
17447        NodeRequest::SetSessionConfigOption {
17448            session,
17449            option_id,
17450            value_json,
17451        } => {
17452            shared.require_controller(connection_id, role)?;
17453            shared.require_acp_session(&session)?;
17454            shared.dispatch(ControlCommand::SetSessionConfigOption {
17455                instance_id: session.session.instance_id,
17456                option_id,
17457                value_json,
17458            })?;
17459            Ok(NodeResponse::Accepted)
17460        }
17461        NodeRequest::SetSessionModel { session, model_id } => {
17462            shared.require_controller(connection_id, role)?;
17463            shared.require_acp_session(&session)?;
17464            shared.dispatch(ControlCommand::SetSessionModel {
17465                instance_id: session.session.instance_id,
17466                model_id,
17467            })?;
17468            Ok(NodeResponse::Accepted)
17469        }
17470        NodeRequest::Shutdown => {
17471            shared.require_controller(connection_id, role)?;
17472            shared.begin_shutdown_locked().await?;
17473            Ok(NodeResponse::ShuttingDown)
17474        }
17475    }
17476}
17477
17478fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17479    shared.require_controller(connection_id, role)?;
17480    shared.validate_address(session)
17481}
17482
17483/// Selects the sole history candidate matching `provider_session_id` when
17484/// the managed record carries a verified identity, or the sole candidate in
17485/// the discovered set at all when it does not (see
17486/// `export_context_pack_for_session_record_inner`'s identity match). Either
17487/// way, more than one match refuses instead of guessing which one is exact.
17488fn select_history_candidate(
17489    candidates: &[HistoryCandidateSummary],
17490    provider_session_id: Option<&str>,
17491) -> Result<String, NodeFailure> {
17492    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17493        Some(id) => candidate.session_id_hint == id,
17494        None => true,
17495    });
17496    let candidate = matches.next().ok_or_else(|| {
17497        failure(
17498            NodeFailureCode::SessionRecordConflict,
17499            if provider_session_id.is_some() {
17500                "no history candidate matches the managed provider session"
17501            } else {
17502                "no history candidate exists for the managed session"
17503            },
17504        )
17505    })?;
17506    if matches.next().is_some() {
17507        return Err(failure(
17508            NodeFailureCode::SessionRecordConflict,
17509            if provider_session_id.is_some() {
17510                "history identity is ambiguous for the managed provider session"
17511            } else {
17512                "history is ambiguous without a verified provider session identity"
17513            },
17514        ));
17515    }
17516    Ok(candidate.id.clone())
17517}
17518
17519fn resolved_spawn_profile_summary(
17520    profile: &SpawnProfileDefaults,
17521    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17522) -> Option<SpawnProfileSummary> {
17523    let environment_profile = match profile.environment_profile_id.as_ref() {
17524        None => None,
17525        Some(profile_id) => {
17526            let binding = environment_profiles.get(profile_id)?;
17527            if binding.provider != profile.provider
17528                || binding.native_profile_id(profile.mode).is_none()
17529            {
17530                return None;
17531            }
17532            Some(ResolvedEnvironmentProfileReceipt {
17533                profile_id: binding.id.clone(),
17534                profile_revision: binding.revision.clone(),
17535                network_allowlist: None,
17536                browser_profile_id: None,
17537            })
17538        }
17539    };
17540    Some(SpawnProfileSummary {
17541        id: profile.profile_id.clone(),
17542        revision: profile.revision.clone(),
17543        environment_profile,
17544    })
17545}
17546
17547/// True if `current` is an acceptable version of `expected` at export
17548/// revalidation/commit time.
17549///
17550/// When `allow_clean_detachment` is `false` (the on-demand,
17551/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17552/// be byte-identical to `expected` and still actively bound to `session` —
17553/// the original, unchanged invariant.
17554///
17555/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17556/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17557/// export reacts to produces on its own: `state` Live -> Dormant,
17558/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17559/// advancing. Every other field — including `provider_session`, `context`,
17560/// `bundle`, `task_binding` — must still match exactly. A record that changed
17561/// in any other way (rebind, a different active session, an identity
17562/// conflict, capacity eviction) is rejected either way; this widens nothing
17563/// about which sessions are eligible, only how the record's own expected
17564/// detachment is tolerated mid-export.
17565fn session_record_export_target_matches(
17566    expected: &ManagedSessionRecord,
17567    current: &ManagedSessionRecord,
17568    session: &SessionAddress,
17569    allow_clean_detachment: bool,
17570) -> bool {
17571    if current == expected && current.active_session.as_ref() == Some(session) {
17572        return current.state == ManagedSessionState::Live;
17573    }
17574    allow_clean_detachment
17575        && current.state == ManagedSessionState::Dormant
17576        && current.active_session.is_none()
17577        && current.record_id == expected.record_id
17578        && current.display_name == expected.display_name
17579        && current.provider == expected.provider
17580        && current.mode == expected.mode
17581        && current.workspace_id == expected.workspace_id
17582        && current.canonical_root == expected.canonical_root
17583        && current.provider_session == expected.provider_session
17584        && current.environment_profile == expected.environment_profile
17585        && current.bundle == expected.bundle
17586        && current.context_id == expected.context_id
17587        && current.context == expected.context
17588        && current.exported_context == expected.exported_context
17589        && current.task_binding == expected.task_binding
17590        && current.created_at_unix_ms == expected.created_at_unix_ms
17591        && current.last_error == expected.last_error
17592}
17593
17594fn session_record_context_export_binding_is_exact(
17595    expected: &ManagedSessionRecord,
17596    current: &ManagedSessionRecord,
17597    identity: Option<&ProviderSessionIdentity>,
17598    session: &SessionAddress,
17599    binding: &SessionBinding,
17600    provider: &AgentId,
17601    current_root: &str,
17602    allow_clean_detachment: bool,
17603) -> bool {
17604    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17605        && current.provider_session.as_ref() == identity
17606        && current.workspace_id == session.workspace_id
17607        && provider == &current.provider
17608        && binding.workspace_id == session.workspace_id
17609        && binding.generation == session.session.generation
17610        && binding.record_id.as_ref() == Some(&current.record_id)
17611        && platform::roots_equal(
17612            current_root,
17613            windows_path_text(&current.canonical_root),
17614        )
17615}
17616
17617fn session_record_context_export_source_is_usable(
17618    record: &ManagedSessionRecord,
17619    status: &SessionStatus,
17620    allow_clean_detachment: bool,
17621) -> bool {
17622    (record.state == ManagedSessionState::Live
17623        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17624        && context_pack_source_status_is_usable(status)
17625}
17626
17627fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17628    PromptFraming::BracketedPaste
17629}
17630
17631fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17632    if text.len() > MAX_NODE_TEXT_BYTES {
17633        return Err(failure(
17634            NodeFailureCode::InvalidRequest,
17635            &format!(
17636                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17637                text.len(),
17638            ),
17639        ));
17640    }
17641    Ok(())
17642}
17643
17644fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17645    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17646        return Err(failure(
17647            NodeFailureCode::InvalidRequest,
17648            &format!(
17649                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17650                bytes.len(),
17651            ),
17652        ));
17653    }
17654    Ok(())
17655}
17656
17657fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17658    validate_terminal_bytes(&bytes)?;
17659    Ok(InputAction::TerminalBytes(bytes))
17660}
17661
17662fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17663    if root.is_empty()
17664        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17665        || root.chars().any(char::is_control)
17666    {
17667        return Err(failure(
17668            NodeFailureCode::InvalidWorkspaceRoot,
17669            &format!(
17670                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17671            ),
17672        ));
17673    }
17674    Ok(())
17675}
17676
17677fn canonical_standalone_workspace_candidate(
17678    workspace_id: &WorkspaceId,
17679    root: &str,
17680) -> Result<String, NodeServerError> {
17681    let path = Path::new(root);
17682    match std::fs::symlink_metadata(path) {
17683        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17684            .map(|workspace| workspace.canonical_root),
17685        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17686            if !path.is_absolute() {
17687                return Err(NodeServerError::InvalidWorkspaceRoot {
17688                    workspace_id: workspace_id.clone(),
17689                    path: root.to_owned(),
17690                    message: "path is not absolute".to_owned(),
17691                });
17692            }
17693            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17694                workspace_id: workspace_id.clone(),
17695                path: root.to_owned(),
17696                message: "path has no standalone directory name".to_owned(),
17697            })?;
17698            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17699                workspace_id: workspace_id.clone(),
17700                path: root.to_owned(),
17701                message: "path has no parent directory".to_owned(),
17702            })?;
17703            let canonical = std::fs::canonicalize(parent)
17704                .map(|parent| parent.join(name))
17705                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17706                    workspace_id: workspace_id.clone(),
17707                    path: root.to_owned(),
17708                    message: error.to_string(),
17709                })?;
17710            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17711                NodeServerError::InvalidWorkspaceRoot {
17712                    workspace_id: workspace_id.clone(),
17713                    path: path.to_string_lossy().into_owned(),
17714                    message: "canonical path is not valid Unicode".to_owned(),
17715                }
17716            })?;
17717            let canonical_root = platform::normalize_canonical_root(canonical_root);
17718            validate_workspace_root(workspace_id, &canonical_root)?;
17719            Ok(canonical_root)
17720        }
17721        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17722            workspace_id: workspace_id.clone(),
17723            path: root.to_owned(),
17724            message: error.to_string(),
17725        }),
17726    }
17727}
17728
17729fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17730    let Some(revision) = revision else {
17731        return Ok(());
17732    };
17733    if revision.is_empty()
17734        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17735        || revision.chars().any(char::is_control)
17736    {
17737        return Err(failure(
17738            NodeFailureCode::InvalidRequest,
17739            &format!(
17740                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17741            ),
17742        ));
17743    }
17744    Ok(())
17745}
17746
17747fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17748    let code = match error.kind {
17749        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17750        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17751        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17752        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17753        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17754        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17755        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17756    };
17757    failure(code, &error.message)
17758}
17759
17760fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17761    let code = match error.kind {
17762        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17763        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17764        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17765            NodeFailureCode::BackendOperationFailed
17766        }
17767        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17768            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17769        }
17770    };
17771    failure(code, error.message)
17772}
17773
17774fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17775    let (code, message) = match error.kind {
17776        GitWorktreeErrorKind::Invalid => (
17777            NodeFailureCode::InvalidRequest,
17778            "managed Git input is invalid",
17779        ),
17780        GitWorktreeErrorKind::NotRepository => (
17781            NodeFailureCode::NotGitRepository,
17782            "managed source is not a Git repository",
17783        ),
17784        GitWorktreeErrorKind::Conflict => (
17785            NodeFailureCode::WorktreeConflict,
17786            "managed Git identity conflicts with existing repository state",
17787        ),
17788        GitWorktreeErrorKind::Protected => (
17789            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17790            "managed worktree ownership validation failed",
17791        ),
17792        GitWorktreeErrorKind::Dirty => (
17793            NodeFailureCode::WorktreeDirty,
17794            "managed worktree contains uncommitted changes",
17795        ),
17796        GitWorktreeErrorKind::Locked => (
17797            NodeFailureCode::WorktreeLocked,
17798            "managed worktree is locked",
17799        ),
17800        GitWorktreeErrorKind::Failed => (
17801            NodeFailureCode::BackendOperationFailed,
17802            "managed Git operation failed",
17803        ),
17804    };
17805    NodeFailure { code, message: message.to_owned() }
17806}
17807
17808fn managed_spawn_receipt(
17809    resolved: &ResolvedSpawnSpec,
17810    incarnation_id: NodeIncarnationId,
17811    session: SessionAddress,
17812    lease: ManagedWorktreeLeaseSnapshot,
17813    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17814    bundle: Option<ResolvedBundleReceipt>,
17815    context: Option<ResolvedContextPackReceipt>,
17816) -> ManagedWorktreeSpawnReceipt {
17817    let mut managed = resolved.clone();
17818    managed.target.worktree_id = Some(lease.workspace_id.clone());
17819    ManagedWorktreeSpawnReceipt {
17820        spawn: managed.receipt_with_materialization(
17821            incarnation_id,
17822            session,
17823            environment_profile,
17824            bundle,
17825            context,
17826        ),
17827        lease,
17828    }
17829}
17830
17831fn validate_selected_worktree(
17832    source_root: &str,
17833    selected_root: &str,
17834    worktrees: &[NativeGitWorktreeSnapshot],
17835) -> Result<(), NodeFailure> {
17836    if !worktrees
17837        .iter()
17838        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17839    {
17840        return Err(failure(
17841            NodeFailureCode::WorktreeProtected,
17842            "source workspace is not an authoritative non-bare Git worktree root",
17843        ));
17844    }
17845    if !worktrees.iter().any(|worktree| {
17846        worktree_paths_equal(&worktree.path, selected_root)
17847            && !worktree.is_main
17848            && !worktree.is_bare
17849    }) {
17850        return Err(failure(
17851            NodeFailureCode::WorktreeProtected,
17852            "selected workspace is not an eligible Git worktree in the source repository",
17853        ));
17854    }
17855    Ok(())
17856}
17857
17858fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17859    let code = match error.kind() {
17860        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17861        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17862            NodeFailureCode::RepositoryPathUnsafe
17863        }
17864        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17865        WorkspaceFileReadErrorKind::NotRegularFile => {
17866            NodeFailureCode::RepositoryFileNotRegular
17867        }
17868        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17869            NodeFailureCode::RepositoryFileReadFailed
17870        }
17871        WorkspaceFileReadErrorKind::RevisionConflict => {
17872            NodeFailureCode::RepositoryFileRevisionConflict
17873        }
17874        WorkspaceFileReadErrorKind::AlreadyExists
17875        | WorkspaceFileReadErrorKind::ParentNotFound
17876        | WorkspaceFileReadErrorKind::ParentNotDirectory
17877        | WorkspaceFileReadErrorKind::Canceled => {
17878            NodeFailureCode::RepositoryFileReadFailed
17879        }
17880    };
17881    failure(code, "repository file read failed")
17882}
17883
17884fn workspace_file_write_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::NotFound => NodeFailureCode::RepositoryFileNotFound,
17891        WorkspaceFileReadErrorKind::NotRegularFile => {
17892            NodeFailureCode::RepositoryFileNotRegular
17893        }
17894        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17895            NodeFailureCode::RepositoryFileWriteFailed
17896        }
17897        WorkspaceFileReadErrorKind::RevisionConflict => {
17898            NodeFailureCode::RepositoryFileRevisionConflict
17899        }
17900        WorkspaceFileReadErrorKind::AlreadyExists
17901        | WorkspaceFileReadErrorKind::ParentNotFound
17902        | WorkspaceFileReadErrorKind::ParentNotDirectory
17903        | WorkspaceFileReadErrorKind::Canceled => {
17904            NodeFailureCode::RepositoryFileWriteFailed
17905        }
17906    };
17907    failure(code, "repository file write failed")
17908}
17909
17910async fn settle_workspace_entry_create<T>(
17911    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17912    commit_state: Arc<AtomicU8>,
17913    deadline: Duration,
17914    timeout_message: &'static str,
17915    task_failure_message: &'static str,
17916) -> Result<T, NodeFailure> {
17917    let joined = match timeout(deadline, &mut task).await {
17918        Ok(joined) => joined,
17919        Err(_) => {
17920            if commit_state
17921                .compare_exchange(
17922                    WORKSPACE_ENTRY_CREATE_PENDING,
17923                    WORKSPACE_ENTRY_CREATE_CANCELED,
17924                    Ordering::AcqRel,
17925                    Ordering::Acquire,
17926                )
17927                .is_ok()
17928            {
17929                return Err(failure(
17930                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17931                    timeout_message,
17932                ));
17933            }
17934            debug_assert_eq!(
17935                commit_state.load(Ordering::Acquire),
17936                WORKSPACE_ENTRY_CREATE_COMMITTING,
17937            );
17938            task.await
17939        }
17940    };
17941    joined
17942        .map_err(|_| {
17943            failure(
17944                NodeFailureCode::RepositoryEntryCreateFailed,
17945                task_failure_message,
17946            )
17947        })?
17948        .map_err(workspace_entry_create_failure)
17949}
17950
17951fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17952    let code = match error.kind() {
17953        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17954        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17955            NodeFailureCode::RepositoryPathUnsafe
17956        }
17957        WorkspaceFileReadErrorKind::AlreadyExists => {
17958            NodeFailureCode::RepositoryEntryAlreadyExists
17959        }
17960        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17961            NodeFailureCode::RepositoryParentNotFound
17962        }
17963        WorkspaceFileReadErrorKind::ParentNotDirectory
17964        | WorkspaceFileReadErrorKind::NotRegularFile => {
17965            NodeFailureCode::RepositoryParentNotDirectory
17966        }
17967        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17968            NodeFailureCode::RepositoryEntryCreateFailed
17969        }
17970        WorkspaceFileReadErrorKind::RevisionConflict => {
17971            NodeFailureCode::RepositoryEntryCreateFailed
17972        }
17973        WorkspaceFileReadErrorKind::Canceled => {
17974            NodeFailureCode::RepositoryEntryCreateTimedOut
17975        }
17976    };
17977    failure(code, "repository entry create failed")
17978}
17979
17980fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17981    let value = digest(&SHA256, bytes)
17982        .as_ref()
17983        .iter()
17984        .map(|byte| format!("{byte:02x}"))
17985        .collect();
17986    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17987}
17988
17989fn managed_spawn_request_digest_v2(
17990    request: &ManagedWorktreeSpawnRequestV2,
17991) -> Result<String, NodeFailure> {
17992    let encoded = serde_json::to_vec(request).map_err(|_| {
17993        failure(
17994            NodeFailureCode::InvalidRequest,
17995            "managed worktree V2 request could not be canonicalized",
17996        )
17997    })?;
17998    let mut value = String::with_capacity(71);
17999    value.push_str("sha256:");
18000    for byte in digest(&SHA256, &encoded).as_ref() {
18001        use std::fmt::Write as _;
18002        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18003    }
18004    Ok(value)
18005}
18006
18007fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18008    let code = match error {
18009        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18010        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18011        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18012        NativeSessionPreviewError::UnsupportedProvider
18013        | NativeSessionPreviewError::PreviewUnavailable
18014        | NativeSessionPreviewError::SessionNotFound
18015        | NativeSessionPreviewError::AmbiguousSession => {
18016            NodeFailureCode::BackendOperationFailed
18017        }
18018    };
18019    failure(code, "native session preview failed")
18020}
18021
18022fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18023    let code = match error.kind() {
18024        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18025        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18026    };
18027    failure(code, "host directory browse failed")
18028}
18029
18030fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18031    let code = match error {
18032        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18033        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18034        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18035            NodeFailureCode::DeliveryStageConflict
18036        }
18037        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18038        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18039            NodeFailureCode::DeliveryChunkOutOfOrder
18040        }
18041        DeliveryStoreError::BlobDigestMismatch => {
18042            NodeFailureCode::DeliveryBlobDigestMismatch
18043        }
18044        DeliveryStoreError::BundleDigestMismatch => {
18045            NodeFailureCode::DeliveryBundleDigestMismatch
18046        }
18047        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18048        DeliveryStoreError::Unavailable
18049        | DeliveryStoreError::Corrupt
18050        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18051    };
18052    failure(code, "delivery operation failed")
18053}
18054
18055fn validate_workspace_root(
18056    workspace_id: &WorkspaceId,
18057    canonical_root: &str,
18058) -> Result<(), NodeServerError> {
18059    #[cfg(windows)]
18060    if canonical_root.starts_with(r"\\") {
18061        return Err(NodeServerError::InvalidWorkspaceRoot {
18062            workspace_id: workspace_id.clone(),
18063            path: canonical_root.to_owned(),
18064            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18065        });
18066    }
18067    if !platform::workspace_root_supported(canonical_root)
18068        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18069    {
18070        return Err(NodeServerError::InvalidWorkspaceRoot {
18071            workspace_id: workspace_id.clone(),
18072            path: canonical_root.to_owned(),
18073            message: "canonical path must be absolute, bounded, and free of control characters"
18074                .to_owned(),
18075        });
18076    }
18077    Ok(())
18078}
18079
18080fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18081    NodeFailure {
18082        code,
18083        message: node_failure_category(code).to_owned(),
18084    }
18085}
18086
18087fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18088    let code = match error {
18089        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18090        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18091        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18092        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18093        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18094        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18095        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18096        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18097        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18098    };
18099    failure(code, "harness MCP proxy operation failed")
18100}
18101
18102/// Numbers each harness-MCP overlay this process constructs, in
18103/// construction order, so two overlays built back to back in one node process
18104/// still get distinct trace files.
18105static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18106
18107/// Builds the optional trace environment entry of one harness-MCP overlay.
18108/// The caller's launch description names both the entry and the node-process
18109/// variable that opts in: when that variable holds a non-empty directory, the
18110/// entry names a trace file under it. Returns `None` when the launch has no
18111/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18112/// the directory; that is the operator's job.
18113fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18114    let trace = launch.trace.as_ref()?;
18115    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18116    if trace_dir.is_empty() {
18117        return None;
18118    }
18119    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18120    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18121    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18122}
18123
18124fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18125    let remaining = deadline.saturating_duration_since(Instant::now());
18126    if remaining.is_zero() {
18127        Err(failure(
18128            NodeFailureCode::SpawnDeadlineExceeded,
18129            "spawn deadline elapsed",
18130        ))
18131    } else {
18132        Ok(remaining)
18133    }
18134}
18135
18136fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18137    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18138    match deadline {
18139        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18140        None => Ok(bounded),
18141    }
18142}
18143
18144fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18145    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18146        failure(
18147            NodeFailureCode::SpawnDeadlineExceeded,
18148            "spawn dispatch exceeded the spawn deadline",
18149        )
18150    } else {
18151        error
18152    }
18153}
18154
18155fn spawn_runtime_capabilities(
18156    capabilities: &SpawnRequiredCapabilities,
18157) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18158    capabilities
18159        .iter()
18160        .map(|capability| match capability.as_str() {
18161            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18162            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18163            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18164            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18165                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18166            }
18167            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18168            _ => Err(failure(
18169                NodeFailureCode::UnsupportedSpawnCapability,
18170                "spawn required capability is unknown",
18171            )),
18172        })
18173        .collect()
18174}
18175
18176fn persistence_failure(_error: io::Error) -> NodeFailure {
18177    NodeFailure {
18178        code: NodeFailureCode::BackendOperationFailed,
18179        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18180    }
18181}
18182
18183fn node_failure_category(code: NodeFailureCode) -> &'static str {
18184    match code {
18185        NodeFailureCode::InvalidRequest => "invalid-request",
18186        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18187        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18188        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18189        NodeFailureCode::ReservationConflict => "reservation-conflict",
18190        NodeFailureCode::ReservationExpired => "reservation-expired",
18191        NodeFailureCode::BindingMismatch => "binding-mismatch",
18192        NodeFailureCode::NotActivated => "not-activated",
18193        NodeFailureCode::CallNotFound => "call-not-found",
18194        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18195        NodeFailureCode::ResponseTooLarge => "response-too-large",
18196        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18197        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18198        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18199        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18200        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18201        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18202        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18203        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18204        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18205        NodeFailureCode::Unauthorized => "unauthorized",
18206        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18207        NodeFailureCode::ControllerBusy => "controller-busy",
18208        NodeFailureCode::ControllerRequired => "controller-required",
18209        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18210        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18211        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18212        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18213        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18214        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18215        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18216        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18217        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18218        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18219        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18220        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18221        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18222        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18223        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18224        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18225        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18226        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18227        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18228        NodeFailureCode::GitReadFailed => "git-read-failed",
18229        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18230        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18231        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18232        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18233        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18234        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18235        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18236        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18237        NodeFailureCode::TurnInFlight => "turn-in-flight",
18238        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18239        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18240        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18241            "unsupported-network-allowlist-mapping"
18242        }
18243        NodeFailureCode::BrowserStationProbeUnavailable => {
18244            "browser-station-probe-unavailable"
18245        }
18246        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18247        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18248        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18249            "environment-profile-binding-mismatch"
18250        }
18251        NodeFailureCode::UnknownBundle => "unknown-bundle",
18252        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18253        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18254        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18255        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18256        NodeFailureCode::ContextPackMaterializationFailed => {
18257            "context-pack-materialization-failed"
18258        }
18259        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18260        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18261        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18262        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18263        NodeFailureCode::LastWorkspace => "last-workspace",
18264        NodeFailureCode::NotGitRepository => "not-git-repository",
18265        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18266        NodeFailureCode::WorktreeProtected => "worktree-protected",
18267        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18268        NodeFailureCode::WorktreeLocked => "worktree-locked",
18269        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18270        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18271        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18272        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18273            "managed-worktree-profile-revision-mismatch"
18274        }
18275        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18276        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18277            "standalone-workspace-recovery-required"
18278        }
18279        NodeFailureCode::UnknownSession => "unknown-session",
18280        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18281        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18282        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18283        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18284        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18285        NodeFailureCode::WorkspaceRegistrationRequired => {
18286            "workspace-registration-required"
18287        }
18288        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18289        NodeFailureCode::StaleGeneration => "stale-generation",
18290        NodeFailureCode::BackendBusy => "backend-busy",
18291        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18292        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18293        NodeFailureCode::ShuttingDown => "shutting-down",
18294    }
18295}
18296
18297fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18298    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18299}
18300
18301fn durable_state_load_error(error: io::Error) -> NodeServerError {
18302    let category = match session_registry::state_load_refusal(&error) {
18303        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18304            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18305        }
18306        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18307            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18308        }
18309        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18310            DURABLE_STATE_CONFLICT_ERROR
18311        }
18312        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18313    };
18314    durable_state_server_error(error, category)
18315}
18316
18317fn unix_time_ms() -> u64 {
18318    std::time::SystemTime::now()
18319        .duration_since(std::time::UNIX_EPOCH)
18320        .unwrap_or_default()
18321        .as_millis()
18322        .min(u64::MAX as u128) as u64
18323}
18324
18325fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18326    if record.provider_session.is_some() {
18327        ManagedSessionState::Dormant
18328    } else {
18329        ManagedSessionState::Unavailable
18330    }
18331}
18332
18333fn active_record_state(
18334    identity_admitted: bool,
18335    provider_session_is_known: bool,
18336) -> ManagedSessionState {
18337    if identity_admitted && !provider_session_is_known {
18338        ManagedSessionState::IdentityPending
18339    } else {
18340        ManagedSessionState::Live
18341    }
18342}
18343
18344fn managed_worktree_record_holders(
18345    records: &[ManagedSessionRecord],
18346) -> Vec<(SessionRecordId, WorkspaceId)> {
18347    records
18348        .iter()
18349        .filter(|record| record.provider_session.is_some())
18350        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18351        .collect()
18352}
18353
18354fn managed_spawn_record_ownership(
18355    binding: &SessionBinding,
18356    effective_runtime_policy: ProviderRuntimePolicy,
18357) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18358    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18359    if effective_runtime_policy.provider_session_identity {
18360        (binding.record_id.clone(), None)
18361    } else {
18362        (None, binding.record_id.clone())
18363    }
18364}
18365
18366#[derive(Debug, Error)]
18367pub enum NodeServerError {
18368    #[error("node endpoint must be a bounded absolute local transport path")]
18369    InvalidEndpoint,
18370    #[error("node access token must contain 1..=4096 bytes")]
18371    InvalidAccessToken,
18372    #[error("node HTTP observer must listen on a loopback address: {0}")]
18373    InvalidApiListen(std::net::SocketAddr),
18374    #[error("node browser bridge must listen on a loopback address: {0}")]
18375    InvalidBridgeListen(std::net::SocketAddr),
18376    #[error("node browser bridge token must contain 1..=4096 bytes")]
18377    InvalidBridgeToken,
18378    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18379    BridgeUnderlayRequiresBridgeListen,
18380    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18381    BridgeUnderlayRequiresBridgeToken,
18382    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18383    InvalidBridgeUnderlayToken,
18384    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18385    InvalidBridgeUnderlayKey,
18386    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18387    InvalidCallHome(std::net::SocketAddr),
18388    #[error("node could not reach its call-home relay: {0}")]
18389    CallHomeTransport(#[source] std::io::Error),
18390    #[error("node durable state path must be an absolute file path: {0}")]
18391    InvalidStatePath(String),
18392    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18393    InvalidHarnessMcpHelper,
18394    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18395    InvalidNetworkAllowlistCatalogPath(String),
18396    #[error("network allowlist catalog could not be read: {0}")]
18397    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18398    #[error("network allowlist catalog line {line}: {message}")]
18399    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18400    #[error("network allowlist catalog: {0}")]
18401    InvalidNetworkAllowlistCatalog(String),
18402    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18403    NetworkAllowlistCatalogCapacity { max: usize },
18404    #[error("the local state directory is unavailable; supply an explicit state path")]
18405    LocalStateDirectoryUnavailable,
18406    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18407    LocalRuntimeDirectoryUnavailable,
18408    #[error("node durable state failed: {0}")]
18409    DurableState(io::Error),
18410    #[error("node requires at least one configured workspace")]
18411    NoWorkspaces,
18412    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18413    InvalidWorkspaceRoot {
18414        workspace_id: WorkspaceId,
18415        path: String,
18416        message: String,
18417    },
18418    #[error("duplicate workspace ID: {0}")]
18419    DuplicateWorkspaceId(WorkspaceId),
18420    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18421    DuplicateWorkspaceRoot {
18422        first: WorkspaceId,
18423        second: WorkspaceId,
18424        root: String,
18425    },
18426    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18427    ManagedWorktreeProfileRequired(WorkspaceId),
18428    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18429    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18430    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18431    DuplicateManagedWorktreeProfile {
18432        workspace_id: WorkspaceId,
18433        profile_id: WorktreeProfileId,
18434    },
18435    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18436    ManagedWorktreeProfileCapacity {
18437        workspace_id: WorkspaceId,
18438        max: usize,
18439    },
18440    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18441    InvalidManagedWorktreeProfile {
18442        workspace_id: WorkspaceId,
18443        profile_id: WorktreeProfileId,
18444        message: String,
18445    },
18446    #[error("duplicate node environment profile: {0}")]
18447    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18448    #[error("node environment profile provider is unavailable: {0}")]
18449    EnvironmentProfileProviderUnavailable(AgentId),
18450    #[error("node environment profile capacity is {max}")]
18451    EnvironmentProfileCapacity { max: usize },
18452    #[error("node environment profiles reuse native profile ID '{0}'")]
18453    DuplicateNativeEnvironmentProfile(String),
18454    #[error("node native environment profile is invalid: {0}")]
18455    NativeEnvironmentProfile(String),
18456    #[error("node session-environment materialization root must be absolute")]
18457    InvalidSessionEnvironmentRoot,
18458    #[error("node session-environment materializer is unavailable")]
18459    SessionEnvironmentMaterializerRequired,
18460    #[error("node session-environment materializer failed to initialize")]
18461    SessionEnvironmentMaterializer,
18462    #[error("node bundle catalog is invalid: {0}")]
18463    BundleCatalog(String),
18464    #[error("node delivery store failed to initialize: {0}")]
18465    DeliveryStore(String),
18466    #[error("node context pack store failed to initialize")]
18467    ContextPackStore,
18468    #[error("active agent registry failed: {0}")]
18469    Registry(String),
18470    #[error("node provider contract manifest is invalid: {0}")]
18471    ProviderContractManifest(String),
18472    #[error("named pipe I/O failed: {0}")]
18473    Io(#[from] io::Error),
18474    #[error("node HTTP observer failed: {0}")]
18475    HttpApi(io::Error),
18476    #[error("node browser bridge failed: {0}")]
18477    BridgeApi(io::Error),
18478    #[error(transparent)]
18479    Frame(#[from] FrameError),
18480    #[error("node handshake failed: {0}")]
18481    Handshake(String),
18482    #[error("node authentication frame was not received before the bounded deadline")]
18483    AuthenticationTimedOut,
18484    #[error("node authentication primitive failed: {0}")]
18485    Authentication(String),
18486    #[error("node incarnation identity generation failed: {0}")]
18487    IncarnationIdentity(String),
18488    #[error("node pre-authentication limiter is closed")]
18489    PreauthClosed,
18490    #[error("node authenticated connection limit was reached")]
18491    AuthenticatedConnectionLimit,
18492    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18493    SignalStreamClosed,
18494    #[error("node stop-all dispatch failed: {0:?}")]
18495    ShutdownDispatch(NodeFailure),
18496    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18497    ShutdownTimedOut { active_native_sessions: usize },
18498}
18499
18500impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18501    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18502        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18503        match error {
18504            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18505            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18506            E::InvalidEntry { line, message } => {
18507                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18508            }
18509            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18510            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18511        }
18512    }
18513}
18514
18515#[cfg(test)]
18516mod agent_stream_blocked_tests {
18517    use super::*;
18518    use gate4agent_types::{
18519        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18520        ProviderSource,
18521    };
18522
18523    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18524        ProviderSource {
18525            family,
18526            binding: AdapterBinding::new(
18527                AdapterId::new(adapter).unwrap(),
18528                "fixture/v1",
18529                AdapterVerification::SyntheticFixture,
18530            )
18531            .unwrap(),
18532        }
18533    }
18534
18535    fn provider_control_event_at(
18536        family: AdapterFamily,
18537        adapter: &str,
18538        source_sequence: u64,
18539        provider_event: ProviderEvent,
18540    ) -> ControlEvent {
18541        ControlEvent {
18542            sequence: 41,
18543            command_id: None,
18544            instance_id: AgentInstanceId(7),
18545            generation: SessionGeneration(3),
18546            event: ControlEventKind::ProviderEvent {
18547                sequence: 8,
18548                source: provider_source(family, adapter),
18549                source_sequence,
18550                event: provider_event,
18551            },
18552        }
18553    }
18554
18555    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18556        provider_control_event_at(family, "fixture", 9, provider_event)
18557    }
18558
18559    fn denied_host_request(
18560        by: ProviderHostDecisionAuthority,
18561        reason: Option<String>,
18562    ) -> ControlEvent {
18563        provider_control_event_at(
18564            AdapterFamily::Acp,
18565            "claude-code",
18566            2,
18567            ProviderEvent::HostRequestObserved {
18568                method: "terminal/create".to_owned(),
18569                params_json: String::new(),
18570                decision: ProviderHostRequestDecision::Denied { by },
18571                outcome: ProviderHostRequestOutcome::Executed,
18572                reason,
18573            },
18574        )
18575    }
18576
18577    fn blocked(
18578        event: &ControlEvent,
18579    ) -> (
18580        Option<String>,
18581        String,
18582        BlockAuthorityV1,
18583        Option<String>,
18584        String,
18585        Option<String>,
18586    ) {
18587        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18588        chunk.validate().expect("minted chunk must validate");
18589        let AgentStreamChunkKindV1::Blocked {
18590            correlation_id,
18591            tool_class,
18592            authority,
18593            reason_kind,
18594            reason,
18595            help,
18596        } = chunk.kind
18597        else {
18598            panic!("expected AgentStreamChunkKindV1::Blocked");
18599        };
18600        (correlation_id, tool_class, authority, reason_kind, reason, help)
18601    }
18602
18603    /// Pins the fleet and every adapter each member declares: the fleet is
18604    /// exactly what `active_registry` says, and no member declares a hook
18605    /// adapter (lifecycle hooks are retired).
18606    #[test]
18607    fn the_enabled_provider_matrix_is_exact() {
18608        let registry = active_registry().unwrap();
18609        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18610        assert_eq!(
18611            providers
18612                .iter()
18613                .map(|contract| contract.provider.as_str())
18614                .collect::<Vec<_>>(),
18615            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18616            vec!["shell", "claude", "codex", "grok", "kimi"],
18617        );
18618        assert!(adapters.iter().all(|contract| {
18619            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18620        }));
18621    }
18622
18623    /// A gate-denied host request, WITH its reason text carried all the way
18624    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18625    #[test]
18626    fn gate_denied_host_request_mints_a_blocked_chunk() {
18627        let gate_text =
18628            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18629        let event = denied_host_request(
18630            ProviderHostDecisionAuthority::Gate,
18631            Some(gate_text.to_owned()),
18632        );
18633        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18634        assert_eq!(chunk.source_sequence, 2);
18635        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18636        assert_eq!(authority, BlockAuthorityV1::HostGate);
18637        assert_eq!(reason, gate_text);
18638        assert_eq!(correlation_id, None);
18639        assert_eq!(reason_kind, None);
18640        assert_eq!(help, None);
18641        assert_eq!(tool_class, "Write");
18642    }
18643
18644    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.";
18645
18646    /// The owner's classifier sample, verbatim, arriving as a
18647    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18648    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18649    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18650    /// and the guidance tail as `help`.
18651    #[test]
18652    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18653        let event = provider_control_event(
18654            AdapterFamily::Acp,
18655            ProviderEvent::ToolCompleted {
18656                id: "toolu_9".to_owned(),
18657                output: CLASSIFIER_SAMPLE.to_owned(),
18658                is_error: true,
18659                duration_ms: None,
18660                agent_id: None,
18661                non_execution_kind: Some("permission-rule".to_owned()),
18662            },
18663        );
18664        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18665        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18666        assert_eq!(reason, "Blocked by classifier");
18667        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18668        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18669        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18670    }
18671
18672    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18673    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18674    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18675    /// vendor code as `reason_kind`.
18676    #[test]
18677    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18678        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.";
18679        let event = provider_control_event(
18680            AdapterFamily::Acp,
18681            ProviderEvent::SessionEnded {
18682                result: "JSON-RPC error -32603: Internal error".to_owned(),
18683                cost_usd: None,
18684                is_error: true,
18685                stop_reason: Some(ProviderStopReason::ProviderError {
18686                    code: -32603,
18687                    message: message.to_owned(),
18688                    vendor_code: Some("usageLimitExceeded".to_owned()),
18689                }),
18690            },
18691        );
18692        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18693        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18694        assert_eq!(reason, message);
18695        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18696        assert_eq!(tool_class, "Session");
18697    }
18698
18699    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18700    /// `authority: ProviderRefusal`.
18701    #[test]
18702    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18703        let event = provider_control_event(
18704            AdapterFamily::Acp,
18705            ProviderEvent::SessionEnded {
18706                result: "refusal".to_owned(),
18707                cost_usd: None,
18708                is_error: true,
18709                stop_reason: Some(ProviderStopReason::Refusal),
18710            },
18711        );
18712        let (_, _, authority, _, _, _) = blocked(&event);
18713        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18714    }
18715
18716    /// A failed tool completion with no typed `non_execution_kind` -- or one
18717    /// that only says the turn stopped -- is a plain execution failure, never a
18718    /// block; nor is an agent's own narrative claiming one.
18719    #[test]
18720    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18721        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18722            let event = provider_control_event(
18723                AdapterFamily::Acp,
18724                ProviderEvent::ToolCompleted {
18725                    id: "toolu_1".to_owned(),
18726                    output: "command exited with status 1".to_owned(),
18727                    is_error: true,
18728                    duration_ms: Some(12),
18729                    agent_id: None,
18730                    non_execution_kind,
18731                },
18732            );
18733            assert!(agent_stream_chunk(&event).is_none());
18734        }
18735        let narrative = provider_control_event(
18736            AdapterFamily::Acp,
18737            ProviderEvent::ToolCompleted {
18738                id: "toolu_2".to_owned(),
18739                output: "the harness blocked the write, approval needed".to_owned(),
18740                is_error: false,
18741                duration_ms: Some(3),
18742                agent_id: None,
18743                non_execution_kind: None,
18744            },
18745        );
18746        assert!(agent_stream_chunk(&narrative).is_none());
18747    }
18748
18749    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18750    /// execution outcome: a granted request that failed while running was
18751    /// still authorized.
18752    #[test]
18753    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18754        for (decision, outcome) in [
18755            (
18756                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18757                ProviderHostRequestOutcome::Executed,
18758            ),
18759            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18760            (
18761                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18762                ProviderHostRequestOutcome::Failed {
18763                    error: "terminal/create spawn failed: os error 3".to_owned(),
18764                },
18765            ),
18766        ] {
18767            let event = provider_control_event_at(
18768                AdapterFamily::Acp,
18769                "claude-code",
18770                3,
18771                ProviderEvent::HostRequestObserved {
18772                    method: "fs/read_text_file".to_owned(),
18773                    params_json: String::new(),
18774                    decision: decision.clone(),
18775                    outcome,
18776                    reason: None,
18777                },
18778            );
18779            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18780        }
18781    }
18782
18783    /// A deferred `session/request_permission` resolved by an operator or an
18784    /// expired deadline carries no reason text -- the chunk falls back to a
18785    /// categorical sentence naming the authority rather than inventing detail.
18786    #[test]
18787    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18788        for (by, expected_authority, expected_reason) in [
18789            (
18790                ProviderHostDecisionAuthority::Operator,
18791                BlockAuthorityV1::Operator,
18792                "declined by the operator",
18793            ),
18794            (
18795                ProviderHostDecisionAuthority::DeadlinePolicy,
18796                BlockAuthorityV1::HostDeadline,
18797                "denied by host policy after the operator deadline expired",
18798            ),
18799        ] {
18800            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18801            assert_eq!(authority, expected_authority);
18802            assert_eq!(reason, expected_reason);
18803        }
18804    }
18805
18806    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18807    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18808    /// outright rather than truncating.
18809    #[test]
18810    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18811        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18812        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18813            ProviderHostDecisionAuthority::Policy,
18814            Some(oversized),
18815        ));
18816        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18817    }
18818}
18819
18820#[cfg(test)]
18821mod standalone_capability_platform_tests {
18822    use super::*;
18823
18824    #[test]
18825    fn standalone_workspace_capability_requires_windows_identity_guards() {
18826        let capabilities = baseline_capabilities().unwrap();
18827        let advertised = capabilities.iter().any(|capability| {
18828            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18829        });
18830        assert_eq!(advertised, cfg!(windows));
18831
18832        let request = NodeRequest::CreateStandaloneWorkspace {
18833            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18834            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18835            initial_branch: None,
18836        };
18837        assert_eq!(
18838            request_uses_unnegotiated_capability(&request, &capabilities),
18839            !cfg!(windows),
18840        );
18841    }
18842}
18843
18844
18845#[cfg(test)]
18846mod station_knobs_resolve_tests {
18847    use super::*;
18848    use gate4agent_catalog::EnvMutation;
18849    use gate4agent_runtime_native::{
18850        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18851        NativeLaunchProfileId,
18852    };
18853    use std::ffi::OsString;
18854    use std::sync::Arc;
18855
18856    struct EmptyEnvironmentResolver;
18857
18858    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18859        fn resolve_child_environment(
18860            &self,
18861        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18862            Ok(vec![EnvMutation {
18863                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18864                value: None,
18865            }])
18866        }
18867    }
18868
18869    fn agent(value: &str) -> AgentId {
18870        AgentId::new(value).unwrap()
18871    }
18872
18873    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18874        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18875        let profile_revision =
18876            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18877        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18878            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18879            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18880            provider: agent("claude"),
18881            mode: SessionMode::Pty,
18882            terminal_size: gate4agent_types::TerminalSize {
18883                rows: 24,
18884                columns: 80,
18885            },
18886            prompt: None,
18887            bundle_id: None,
18888            context_id: None,
18889            environment_profile_id: Some(profile_id.clone()),
18890        };
18891        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18892        let catalog = active_registry().unwrap();
18893        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18894        let control = runtime.native_launch_profile_control();
18895        let node_profile = NodeEnvironmentProfile::new(
18896            profile_id.clone(),
18897            profile_revision,
18898            agent("claude"),
18899            [NativeLaunchProfile::new(
18900                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18901                agent("claude"),
18902                TransportKind::Pty,
18903                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18904                Arc::new(EmptyEnvironmentResolver),
18905            )
18906            .unwrap()],
18907        )
18908        .unwrap();
18909        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18910        for native_profile in native_profiles {
18911            runtime.upsert_native_launch_profile(native_profile).unwrap();
18912        }
18913        let workspace = WorkspaceConfig::new(
18914            WorkspaceId::new("primary").unwrap(),
18915            std::env::current_dir().unwrap(),
18916        )
18917        .unwrap();
18918        let shared = NodeShared::new_with_incarnation(
18919            handle,
18920            "fixture-token".to_owned(),
18921            NodeId::new("node-station-knobs-test").unwrap(),
18922            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18923            vec![workspace],
18924            vec![agent("claude")],
18925            ProviderRuntimeStatuses::default(),
18926            None,
18927            Vec::new(),
18928            Vec::new(),
18929            spawn_profiles,
18930            Some(control),
18931            None,
18932            None,
18933            None,
18934            Vec::new(),
18935            Vec::new(),
18936            Vec::new(),
18937            Vec::new(),
18938            None,
18939            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18940        );
18941        shared
18942            .environment_profiles
18943            .write()
18944            .unwrap_or_else(|poisoned| poisoned.into_inner())
18945            .insert(binding.id.clone(), binding);
18946        (shared, spawn_defaults)
18947    }
18948
18949    /// Empty-default catalog refuses unknown network_allowlist ids.
18950    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18951    #[test]
18952    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18953        let (shared, defaults) = station_knobs_fixture();
18954        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18955        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18956        let spec = crate::protocol::SpawnSpec {
18957            target: crate::protocol::SpawnTarget {
18958                node_id: shared.node_id.clone(),
18959                workspace_id: WorkspaceId::new("primary").unwrap(),
18960                worktree_id: None,
18961            },
18962            profile_id: defaults.profile_id.clone(),
18963            expected_profile_revision: defaults.revision.clone(),
18964            overrides: crate::protocol::SpawnOverrides {
18965                network_allowlist: Some(unknown),
18966                browser_profile_id: Some(browser),
18967                ..crate::protocol::SpawnOverrides::default()
18968            },
18969            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18970            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18971                "station-knobs-unknown-allowlist",
18972            )
18973            .unwrap(),
18974            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18975        };
18976        let resolved = spec.resolve(&defaults).unwrap();
18977        let failure = shared
18978            .resolve_environment_profile(&resolved)
18979            .expect_err("unknown allowlist must refuse");
18980        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18981    }
18982
18983    /// Registered network_allowlist (+ browser_profile_id when feature off)
18984    /// echo onto the env-profile receipt. Empty catalog still allows omit
18985    /// (None). Empty/whitespace refuse is type-level. Feature-off
18986    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18987    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18988    #[test]
18989    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18990        let (shared, defaults) = station_knobs_fixture();
18991        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18992        #[cfg(not(feature = "dig2-station-probe"))]
18993        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18994        shared
18995            .network_allowlist_catalog
18996            .write()
18997            .unwrap_or_else(|poisoned| poisoned.into_inner())
18998            .insert_id(allowlist.clone())
18999            .unwrap();
19000        let spec = crate::protocol::SpawnSpec {
19001            target: crate::protocol::SpawnTarget {
19002                node_id: shared.node_id.clone(),
19003                workspace_id: WorkspaceId::new("primary").unwrap(),
19004                worktree_id: None,
19005            },
19006            profile_id: defaults.profile_id.clone(),
19007            expected_profile_revision: defaults.revision.clone(),
19008            overrides: crate::protocol::SpawnOverrides {
19009                network_allowlist: Some(allowlist.clone()),
19010                #[cfg(not(feature = "dig2-station-probe"))]
19011                browser_profile_id: Some(browser.clone()),
19012                ..crate::protocol::SpawnOverrides::default()
19013            },
19014            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19015            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19016                .unwrap(),
19017            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19018        };
19019        let resolved = spec.resolve(&defaults).unwrap();
19020        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19021        #[cfg(not(feature = "dig2-station-probe"))]
19022        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19023        let receipt = shared
19024            .resolve_environment_profile(&resolved)
19025            .expect("resolve ok")
19026            .expect("env profile present");
19027        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19028        #[cfg(not(feature = "dig2-station-probe"))]
19029        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19030        #[cfg(feature = "dig2-station-probe")]
19031        assert_eq!(receipt.browser_profile_id, None);
19032        assert_eq!(receipt.profile_id.as_str(), "local-claude");
19033
19034        let omitted = crate::protocol::SpawnSpec {
19035            overrides: crate::protocol::SpawnOverrides::default(),
19036            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19037                .unwrap(),
19038            ..spec
19039        };
19040        let resolved_omit = omitted.resolve(&defaults).unwrap();
19041        let bare = shared
19042            .resolve_environment_profile(&resolved_omit)
19043            .expect("resolve ok")
19044            .expect("env profile present");
19045        assert_eq!(bare.network_allowlist, None);
19046        assert_eq!(bare.browser_profile_id, None);
19047    }
19048
19049    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
19050    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
19051    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
19052    /// unit-tested separately (probe refuses before lease on Linux).
19053    #[cfg(feature = "dig2-station-probe")]
19054    #[test]
19055    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19056        let (shared, defaults) = station_knobs_fixture();
19057        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19058        let spec = crate::protocol::SpawnSpec {
19059            target: crate::protocol::SpawnTarget {
19060                node_id: shared.node_id.clone(),
19061                workspace_id: WorkspaceId::new("primary").unwrap(),
19062                worktree_id: None,
19063            },
19064            profile_id: defaults.profile_id.clone(),
19065            expected_profile_revision: defaults.revision.clone(),
19066            overrides: crate::protocol::SpawnOverrides {
19067                browser_profile_id: Some(browser),
19068                ..crate::protocol::SpawnOverrides::default()
19069            },
19070            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19071            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19072                "station-knobs-probe-refuse",
19073            )
19074            .unwrap(),
19075            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19076        };
19077        let resolved = spec.resolve(&defaults).unwrap();
19078        let failure = shared
19079            .resolve_environment_profile(&resolved)
19080            .expect_err("browser_profile with failing probe must refuse");
19081        #[cfg(windows)]
19082        {
19083            assert!(
19084                matches!(
19085                    failure.code,
19086                    NodeFailureCode::BrowserStationUnreachable
19087                        | NodeFailureCode::BrowserStationProbeUnavailable
19088                ),
19089                "unexpected code {:?}",
19090                failure.code
19091            );
19092        }
19093        #[cfg(not(windows))]
19094        {
19095            assert_eq!(
19096                failure.code,
19097                NodeFailureCode::BrowserStationProbeUnavailable
19098            );
19099        }
19100    }
19101
19102    /// Feature on: omitting browser_profile_id still resolves (no probe).
19103    #[cfg(feature = "dig2-station-probe")]
19104    #[test]
19105    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19106        let (shared, defaults) = station_knobs_fixture();
19107        let spec = crate::protocol::SpawnSpec {
19108            target: crate::protocol::SpawnTarget {
19109                node_id: shared.node_id.clone(),
19110                workspace_id: WorkspaceId::new("primary").unwrap(),
19111                worktree_id: None,
19112            },
19113            profile_id: defaults.profile_id.clone(),
19114            expected_profile_revision: defaults.revision.clone(),
19115            overrides: crate::protocol::SpawnOverrides::default(),
19116            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19117            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19118                "station-knobs-probe-omit",
19119            )
19120            .unwrap(),
19121            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19122        };
19123        let resolved = spec.resolve(&defaults).unwrap();
19124        let receipt = shared
19125            .resolve_environment_profile(&resolved)
19126            .expect("omit browser_profile must not probe")
19127            .expect("env profile present");
19128        assert_eq!(receipt.browser_profile_id, None);
19129    }
19130
19131    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19132    /// refuses a second holder for the same opaque profile id; release on
19133    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19134    #[cfg(feature = "dig2-station-probe")]
19135    #[test]
19136    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19137        let (shared, _defaults) = station_knobs_fixture();
19138        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19139        let workspace_id = WorkspaceId::new("primary").unwrap();
19140        let address_a = SessionAddress {
19141            workspace_id: workspace_id.clone(),
19142            session: SessionKey {
19143                instance_id: AgentInstanceId(101),
19144                generation: SessionGeneration(1),
19145            },
19146        };
19147        let address_b = SessionAddress {
19148            workspace_id: workspace_id.clone(),
19149            session: SessionKey {
19150                instance_id: AgentInstanceId(102),
19151                generation: SessionGeneration(1),
19152            },
19153        };
19154        let guard_a = crate::dig2_station_lease::acquire_lease(
19155            &shared.browser_station_leases,
19156            profile.clone(),
19157            address_a.session.instance_id,
19158            1,
19159        )
19160        .expect("first lease");
19161        let busy = crate::dig2_station_lease::acquire_lease(
19162            &shared.browser_station_leases,
19163            profile.clone(),
19164            address_b.session.instance_id,
19165            2,
19166        )
19167        .expect_err("second holder must refuse");
19168        assert!(matches!(
19169            busy,
19170            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19171        ));
19172        // Simulate a bound session so remove_binding clears the lease.
19173        shared.bind_session_with_policy(
19174            &address_a,
19175            ProviderRuntimePolicy::raw_pty(),
19176            None,
19177        );
19178        guard_a.retain();
19179        assert!(shared.remove_binding(&address_a).is_some());
19180        crate::dig2_station_lease::acquire_lease(
19181            &shared.browser_station_leases,
19182            profile,
19183            address_b.session.instance_id,
19184            3,
19185        )
19186        .expect("reacquire after clear")
19187        .retain();
19188        assert!(
19189            crate::dig2_station_lease::release_lease_for_holder(
19190                &shared.browser_station_leases,
19191                address_b.session.instance_id,
19192            )
19193        );
19194    }
19195
19196    /// Feature-off: no browser_station_leases field; stub echo documented in
19197    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19198    #[cfg(not(feature = "dig2-station-probe"))]
19199    #[test]
19200    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19201        let (shared, defaults) = station_knobs_fixture();
19202        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19203        let spec = crate::protocol::SpawnSpec {
19204            target: crate::protocol::SpawnTarget {
19205                node_id: shared.node_id.clone(),
19206                workspace_id: WorkspaceId::new("primary").unwrap(),
19207                worktree_id: None,
19208            },
19209            profile_id: defaults.profile_id.clone(),
19210            expected_profile_revision: defaults.revision.clone(),
19211            overrides: crate::protocol::SpawnOverrides {
19212                browser_profile_id: Some(browser.clone()),
19213                ..crate::protocol::SpawnOverrides::default()
19214            },
19215            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19216            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19217                .unwrap(),
19218            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19219        };
19220        let resolved = spec.resolve(&defaults).unwrap();
19221        let receipt = shared
19222            .resolve_environment_profile(&resolved)
19223            .expect("stub resolve")
19224            .expect("env profile");
19225        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19226    }
19227
19228
19229    /// Startup file loader registers ids into the empty-default catalog.
19230    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19231    #[test]
19232    fn network_allowlist_catalog_loads_from_temp_file() {
19233        let unique = std::time::SystemTime::now()
19234            .duration_since(std::time::UNIX_EPOCH)
19235            .unwrap()
19236            .as_nanos();
19237        let path = std::env::temp_dir().join(format!(
19238            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19239            std::process::id(),
19240        ));
19241        std::fs::write(
19242            &path,
19243            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19244        )
19245        .unwrap();
19246        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19247            .expect("temp catalog file must load");
19248        let _ = std::fs::remove_file(&path);
19249        assert_eq!(loaded.len(), 2);
19250        let (shared, defaults) = station_knobs_fixture();
19251        {
19252            let mut catalog = shared
19253                .network_allowlist_catalog
19254                .write()
19255                .unwrap_or_else(|poisoned| poisoned.into_inner());
19256            *catalog = loaded;
19257        }
19258        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19259        let spec = crate::protocol::SpawnSpec {
19260            target: crate::protocol::SpawnTarget {
19261                node_id: shared.node_id.clone(),
19262                workspace_id: WorkspaceId::new("primary").unwrap(),
19263                worktree_id: None,
19264            },
19265            profile_id: defaults.profile_id.clone(),
19266            expected_profile_revision: defaults.revision.clone(),
19267            overrides: crate::protocol::SpawnOverrides {
19268                network_allowlist: Some(allowlist.clone()),
19269                ..crate::protocol::SpawnOverrides::default()
19270            },
19271            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19272            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19273                "station-knobs-catalog-file",
19274            )
19275            .unwrap(),
19276            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19277        };
19278        let resolved = spec.resolve(&defaults).unwrap();
19279        let receipt = shared
19280            .resolve_environment_profile(&resolved)
19281            .expect("registered via file load")
19282            .expect("env profile present");
19283        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19284    }
19285
19286    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19287    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19288    #[test]
19289    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19290        let (shared, _defaults) = station_knobs_fixture();
19291        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19292        shared
19293            .network_allowlist_catalog
19294            .write()
19295            .unwrap_or_else(|poisoned| poisoned.into_inner())
19296            .insert_id(allowlist.clone())
19297            .unwrap();
19298        let snapshot = shared.snapshot();
19299        let inventory = snapshot
19300            .launch_inventory
19301            .expect("launch inventory present");
19302        let ids = inventory
19303            .network_allowlists
19304            .expect("non-empty catalog surfaces ids");
19305        assert_eq!(ids, vec![allowlist]);
19306    }
19307
19308    /// Track B: registered id with provider_native codex_network_access refuses
19309    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19310    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19311    #[test]
19312    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19313        let (shared, defaults) = station_knobs_fixture();
19314        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19315        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19316            id: allowlist.clone(),
19317            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19318                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19319                peer: "127.0.0.1:443".to_owned(),
19320            }],
19321            provider_native: Some(
19322                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19323                    codex_network_access: Some(true),
19324                },
19325            ),
19326        };
19327        shared
19328            .network_allowlist_catalog
19329            .write()
19330            .unwrap_or_else(|poisoned| poisoned.into_inner())
19331            .insert(entry)
19332            .unwrap();
19333        let spec = crate::protocol::SpawnSpec {
19334            target: crate::protocol::SpawnTarget {
19335                node_id: shared.node_id.clone(),
19336                workspace_id: WorkspaceId::new("primary").unwrap(),
19337                worktree_id: None,
19338            },
19339            profile_id: defaults.profile_id.clone(),
19340            expected_profile_revision: defaults.revision.clone(),
19341            overrides: crate::protocol::SpawnOverrides {
19342                network_allowlist: Some(allowlist),
19343                ..crate::protocol::SpawnOverrides::default()
19344            },
19345            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19346            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19347                "station-knobs-unsupported-mapping",
19348            )
19349            .unwrap(),
19350            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19351        };
19352        let resolved = spec.resolve(&defaults).unwrap();
19353        assert_eq!(resolved.provider.as_str(), "claude");
19354        let failure = shared
19355            .resolve_environment_profile(&resolved)
19356            .expect_err("claude + codex_network_access must refuse");
19357        assert_eq!(
19358            failure.code,
19359            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19360        );
19361    }
19362
19363    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19364    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19365    /// Never secrets on C2.
19366    #[test]
19367    fn resolve_codex_network_access_overlay_moderate_only() {
19368        let (shared, defaults) = station_knobs_fixture();
19369        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19370        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19371            id: allowlist.clone(),
19372            permits: Vec::new(),
19373            provider_native: Some(
19374                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19375                    codex_network_access: Some(true),
19376                },
19377            ),
19378        };
19379        shared
19380            .network_allowlist_catalog
19381            .write()
19382            .unwrap_or_else(|poisoned| poisoned.into_inner())
19383            .insert(entry)
19384            .unwrap();
19385
19386        let overlay = shared
19387            .resolve_codex_network_access_overlay_args(
19388                &agent("codex"),
19389                SessionMode::Pty,
19390                ApprovalLevel::Moderate,
19391                Some(&allowlist),
19392            )
19393            .expect("Moderate Codex maps network_access");
19394        assert_eq!(
19395            overlay,
19396            vec![
19397                std::ffi::OsString::from("-c"),
19398                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19399            ]
19400        );
19401
19402        let acp = shared
19403            .resolve_codex_network_access_overlay_args(
19404                &agent("codex"),
19405                SessionMode::Acp,
19406                ApprovalLevel::Moderate,
19407                Some(&allowlist),
19408            )
19409            .unwrap();
19410        assert_eq!(acp, overlay);
19411
19412        let inline = shared
19413            .resolve_codex_network_access_overlay_args(
19414                &agent("codex"),
19415                SessionMode::Inline,
19416                ApprovalLevel::Moderate,
19417                Some(&allowlist),
19418            )
19419            .unwrap_err();
19420        assert_eq!(
19421            inline.code,
19422            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19423        );
19424
19425        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19426            let spec = crate::protocol::SpawnSpec {
19427                target: crate::protocol::SpawnTarget {
19428                    node_id: shared.node_id.clone(),
19429                    workspace_id: WorkspaceId::new("primary").unwrap(),
19430                    worktree_id: None,
19431                },
19432                profile_id: defaults.profile_id.clone(),
19433                expected_profile_revision: defaults.revision.clone(),
19434                overrides: crate::protocol::SpawnOverrides {
19435                    provider: crate::protocol::SpawnOverride::Set {
19436                        value: agent("codex"),
19437                    },
19438                    approval_level: Some(level),
19439                    network_allowlist: Some(allowlist.clone()),
19440                    ..crate::protocol::SpawnOverrides::default()
19441                },
19442                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19443                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19444                    "codex-net-refuse-{}",
19445                    match level {
19446                        ApprovalLevel::ReadOnly => "ro",
19447                        ApprovalLevel::FullAuto => "fa",
19448                        _ => "other",
19449                    }
19450                ))
19451                .unwrap(),
19452                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19453            };
19454            let resolved = spec.resolve(&defaults).unwrap();
19455            let failure = shared
19456                .resolve_environment_profile(&resolved)
19457                .expect_err("RO/FullAuto must refuse");
19458            assert_eq!(
19459                failure.code,
19460                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19461            );
19462        }
19463    }
19464
19465    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19466    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19467    /// Never secrets on C2; does not invent provider_native Claude keys.
19468    #[test]
19469    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19470        let (shared, _defaults) = station_knobs_fixture();
19471        let allowlist =
19472            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19473        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19474            id: allowlist.clone(),
19475            permits: vec![
19476                crate::network_allowlist_catalog::NetworkPermitSketch {
19477                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19478                    peer: "github.com:443".to_owned(),
19479                },
19480                crate::network_allowlist_catalog::NetworkPermitSketch {
19481                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19482                    peer: "*.npmjs.org".to_owned(),
19483                },
19484            ],
19485            provider_native: None,
19486        };
19487        shared
19488            .network_allowlist_catalog
19489            .write()
19490            .unwrap_or_else(|poisoned| poisoned.into_inner())
19491            .insert(entry)
19492            .unwrap();
19493
19494        let unique = std::time::SystemTime::now()
19495            .duration_since(std::time::UNIX_EPOCH)
19496            .unwrap()
19497            .as_nanos();
19498        let provider_home = std::env::temp_dir().join(format!(
19499            "gate4agent-claude-provider-home-{}-{unique}",
19500            std::process::id(),
19501        ));
19502        std::fs::create_dir_all(&provider_home).unwrap();
19503
19504        let overlay = shared
19505            .resolve_network_allowlist_overlay_args(
19506                &agent("claude"),
19507                SessionMode::Pty,
19508                ApprovalLevel::Moderate,
19509                Some(&allowlist),
19510                Some(provider_home.as_path()),
19511            )
19512            .expect("Claude PTY settings overlay on unix");
19513        let settings_path = provider_home.join(
19514            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19515        );
19516        assert_eq!(
19517            overlay,
19518            vec![
19519                std::ffi::OsString::from("--settings"),
19520                std::ffi::OsString::from(settings_path.as_os_str()),
19521            ]
19522        );
19523        let body = std::fs::read_to_string(&settings_path).unwrap();
19524        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19525        assert_eq!(value["sandbox"]["enabled"], true);
19526        assert_eq!(
19527            value["sandbox"]["network"]["allowedDomains"],
19528            serde_json::json!(["github.com", "*.npmjs.org"])
19529        );
19530        assert!(settings_path.starts_with(&provider_home));
19531
19532        let acp = shared
19533            .resolve_network_allowlist_overlay_args(
19534                &agent("claude"),
19535                SessionMode::Acp,
19536                ApprovalLevel::Moderate,
19537                Some(&allowlist),
19538                Some(provider_home.as_path()),
19539            )
19540            .expect_err("Claude ACP must refuse settings overlay");
19541        assert_eq!(
19542            acp.code,
19543            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19544        );
19545
19546        let no_home = shared
19547            .resolve_network_allowlist_overlay_args(
19548                &agent("claude"),
19549                SessionMode::Pty,
19550                ApprovalLevel::Moderate,
19551                Some(&allowlist),
19552                None,
19553            )
19554            .expect_err("Claude overlay requires ProviderHome");
19555        assert_eq!(
19556            no_home.code,
19557            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19558        );
19559
19560        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19561        shared
19562            .network_allowlist_catalog
19563            .write()
19564            .unwrap_or_else(|poisoned| poisoned.into_inner())
19565            .insert_id(bare.clone())
19566            .unwrap();
19567        let empty = shared
19568            .resolve_network_allowlist_overlay_args(
19569                &agent("claude"),
19570                SessionMode::Pty,
19571                ApprovalLevel::Moderate,
19572                Some(&bare),
19573                Some(provider_home.as_path()),
19574            )
19575            .unwrap();
19576        assert!(empty.is_empty());
19577
19578        let _ = std::fs::remove_dir_all(&provider_home);
19579    }
19580
19581    /// Kimi / Grok with permits stay without invented argv overlays.
19582    #[test]
19583    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19584        let (shared, _defaults) = station_knobs_fixture();
19585        let allowlist =
19586            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19587        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19588            id: allowlist.clone(),
19589            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19590                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19591                peer: "example.com:443".to_owned(),
19592            }],
19593            provider_native: None,
19594        };
19595        shared
19596            .network_allowlist_catalog
19597            .write()
19598            .unwrap_or_else(|poisoned| poisoned.into_inner())
19599            .insert(entry)
19600            .unwrap();
19601        for provider in ["kimi", "grok"] {
19602            let overlay = shared
19603                .resolve_network_allowlist_overlay_args(
19604                    &agent(provider),
19605                    SessionMode::Pty,
19606                    ApprovalLevel::Moderate,
19607                    Some(&allowlist),
19608                    None,
19609                )
19610                .unwrap();
19611            assert!(
19612                overlay.is_empty(),
19613                "{provider} must not invent network argv from permits"
19614            );
19615        }
19616    }
19617
19618    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19619    #[test]
19620    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19621        let unique = std::time::SystemTime::now()
19622            .duration_since(std::time::UNIX_EPOCH)
19623            .unwrap()
19624            .as_nanos();
19625        let path = std::env::temp_dir().join(format!(
19626            "gate4agent-node-allowlist-json-{}-{unique}.json",
19627            std::process::id(),
19628        ));
19629        std::fs::write(
19630            &path,
19631            r#"{
19632              "schema_version": 2,
19633              "entries": [
19634                {
19635                  "id": "egress-default",
19636                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19637                },
19638                { "id": "lab-egress" }
19639              ]
19640            }"#,
19641        )
19642        .unwrap();
19643        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19644            .expect("json v2 catalog must load");
19645        let _ = std::fs::remove_file(&path);
19646        assert_eq!(loaded.len(), 2);
19647        let entry = loaded
19648            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19649            .unwrap();
19650        assert_eq!(entry.permits.len(), 1);
19651        let (shared, defaults) = station_knobs_fixture();
19652        {
19653            let mut catalog = shared
19654                .network_allowlist_catalog
19655                .write()
19656                .unwrap_or_else(|poisoned| poisoned.into_inner());
19657            *catalog = loaded;
19658        }
19659        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19660        let spec = crate::protocol::SpawnSpec {
19661            target: crate::protocol::SpawnTarget {
19662                node_id: shared.node_id.clone(),
19663                workspace_id: WorkspaceId::new("primary").unwrap(),
19664                worktree_id: None,
19665            },
19666            profile_id: defaults.profile_id.clone(),
19667            expected_profile_revision: defaults.revision.clone(),
19668            overrides: crate::protocol::SpawnOverrides {
19669                network_allowlist: Some(allowlist.clone()),
19670                ..crate::protocol::SpawnOverrides::default()
19671            },
19672            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19673            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19674                "station-knobs-catalog-json",
19675            )
19676            .unwrap(),
19677            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19678        };
19679        let resolved = spec.resolve(&defaults).unwrap();
19680        let receipt = shared
19681            .resolve_environment_profile(&resolved)
19682            .expect("permits-only entry ok for claude")
19683            .expect("env profile present");
19684        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19685        let inventory = shared
19686            .snapshot()
19687            .launch_inventory
19688            .expect("launch inventory present");
19689        let ids = inventory.network_allowlists.expect("ids surface");
19690        assert_eq!(
19691            ids,
19692            vec![
19693                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19694                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19695            ]
19696        );
19697    }
19698
19699
19700}
19701
19702
19703#[cfg(test)]
19704mod workspace_environment_collision_tests {
19705    use super::*;
19706    use crate::session_environment::{
19707        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19708    };
19709    use gate4agent_catalog::EnvMutation;
19710    use gate4agent_runtime_native::{
19711        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19712        NativeLaunchProfileId,
19713    };
19714    use std::ffi::OsString;
19715    use std::sync::Arc;
19716
19717    struct EmptyEnvironmentResolver;
19718
19719    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19720        fn resolve_child_environment(
19721            &self,
19722        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19723            Ok(vec![EnvMutation {
19724                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19725                value: None,
19726            }])
19727        }
19728    }
19729
19730    fn agent(value: &str) -> AgentId {
19731        AgentId::new(value).unwrap()
19732    }
19733
19734    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19735    fn claude_config_dir_collision_fixture() -> (
19736        NodeShared,
19737        ResolvedEnvironmentProfileReceipt,
19738        ResolvedEnvironmentProfileReceipt,
19739    ) {
19740        let catalog = active_registry().unwrap();
19741        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19742        let control = runtime.native_launch_profile_control();
19743        let workspace = WorkspaceConfig::new(
19744            WorkspaceId::new("primary").unwrap(),
19745            std::env::current_dir().unwrap(),
19746        )
19747        .unwrap();
19748        let shared = NodeShared::new_with_incarnation(
19749            handle,
19750            "fixture-token".to_owned(),
19751            NodeId::new("node-env-collision-test").unwrap(),
19752            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19753            vec![workspace],
19754            vec![agent("claude")],
19755            ProviderRuntimeStatuses::default(),
19756            None,
19757            Vec::new(),
19758            Vec::new(),
19759            SpawnProfileRegistry::default(),
19760            Some(control),
19761            None,
19762            None,
19763            None,
19764            Vec::new(),
19765            Vec::new(),
19766            Vec::new(),
19767            Vec::new(),
19768            None,
19769            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19770        );
19771
19772        let mut receipts = Vec::new();
19773        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19774            let profile_id =
19775                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19776            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19777                "claude-config-dir-{suffix}-r1"
19778            ))
19779            .unwrap();
19780            let materialization = NodeSessionMaterializationProfile::new(
19781                Vec::new(),
19782                vec![NodeSessionPathBinding::new(
19783                    "CLAUDE_CONFIG_DIR",
19784                    NodeSessionPathClass::ProviderHome,
19785                )
19786                .unwrap()],
19787                Vec::new(),
19788            )
19789            .unwrap();
19790            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19791                profile_id.clone(),
19792                profile_revision.clone(),
19793                agent("claude"),
19794                [NativeLaunchProfile::new(
19795                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19796                        .unwrap(),
19797                    agent("claude"),
19798                    TransportKind::Pty,
19799                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19800                    Arc::new(EmptyEnvironmentResolver),
19801                )
19802                .unwrap()],
19803                Some(materialization),
19804            )
19805            .unwrap();
19806            let (binding, native_profiles, materialization) = node_profile.into_parts();
19807            for native_profile in native_profiles {
19808                runtime.upsert_native_launch_profile(native_profile).unwrap();
19809            }
19810            shared
19811                .environment_profiles
19812                .write()
19813                .unwrap_or_else(|poisoned| poisoned.into_inner())
19814                .insert(binding.id.clone(), binding);
19815            shared
19816                .environment_materialization_profiles
19817                .write()
19818                .unwrap_or_else(|poisoned| poisoned.into_inner())
19819                .insert(profile_id.clone(), materialization.unwrap());
19820            receipts.push(ResolvedEnvironmentProfileReceipt {
19821                profile_id,
19822                profile_revision,
19823                network_allowlist: None,
19824                browser_profile_id: None,
19825            });
19826        }
19827        let profile_b = receipts.pop().unwrap();
19828        let profile_a = receipts.pop().unwrap();
19829        (shared, profile_a, profile_b)
19830    }
19831
19832    #[test]
19833    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19834        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19835        let primary = WorkspaceId::new("primary").unwrap();
19836        let worktree = WorkspaceId::new("worktree-b").unwrap();
19837        let address = SessionAddress {
19838            workspace_id: primary.clone(),
19839            session: SessionKey {
19840                instance_id: AgentInstanceId(41),
19841                generation: SessionGeneration(1),
19842            },
19843        };
19844        shared.bind_session_with_policy(
19845            &address,
19846            ProviderRuntimePolicy::raw_pty(),
19847            Some(profile_a.clone()),
19848        );
19849
19850        let refused = shared
19851            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19852            .unwrap_err();
19853        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19854
19855        // Some vs None on the same cwd is also a collision.
19856        let refused_none = shared
19857            .ensure_workspace_environment_profile_compatible(&primary, None)
19858            .unwrap_err();
19859        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19860
19861        // Same profile on the same workspace remains allowed.
19862        shared
19863            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19864            .unwrap();
19865
19866        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19867        shared
19868            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19869            .unwrap();
19870    }
19871
19872    #[tokio::test]
19873    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19874        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19875        let primary = WorkspaceId::new("primary").unwrap();
19876        let address = SessionAddress {
19877            workspace_id: primary.clone(),
19878            session: SessionKey {
19879                instance_id: AgentInstanceId(41),
19880                generation: SessionGeneration(1),
19881            },
19882        };
19883        shared.bind_session_with_policy(
19884            &address,
19885            ProviderRuntimePolicy::raw_pty(),
19886            Some(profile_a),
19887        );
19888
19889        let failure = shared
19890            .spawn_session_with_deadline(
19891                primary,
19892                agent("claude"),
19893                SessionMode::Pty,
19894                gate4agent_types::TerminalSize {
19895                    rows: 24,
19896                    columns: 80,
19897                },
19898                None,
19899                Some(profile_b),
19900                None,
19901                None,
19902                None,
19903                None,
19904                SpawnRecordPolicy::ProviderIdentityOnly,
19905                Some(Instant::now() + Duration::from_secs(30)),
19906                &[],
19907                None,
19908                ApprovalLevel::default(),
19909                None,
19910                None,
19911            )
19912            .await
19913            .unwrap_err();
19914        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19915        // Guard runs before instance id mint / bind.
19916        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19917        assert!(shared.handle.snapshot().sessions.is_empty());
19918    }
19919}
19920
19921#[cfg(test)]
19922mod spawn_characterization_tests {
19923    use super::*;
19924    use crate::session_environment::{
19925        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19926        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19927        NodeSessionPathClass,
19928    };
19929    use gate4agent_catalog::EnvMutation;
19930    use gate4agent_runtime_native::{
19931        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19932        NativeLaunchProfile, NativeLaunchProfileId,
19933    };
19934    use std::ffi::OsString;
19935    use std::sync::Arc;
19936
19937    struct EmptyEnvironmentResolver;
19938
19939    struct FixtureSecretResolver {
19940        deny: Arc<AtomicBool>,
19941    }
19942
19943    impl NodeSecretResolver for FixtureSecretResolver {
19944        fn resolve(
19945            &self,
19946            _reference: &NodeSecretReference,
19947        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19948            if self.deny.load(Ordering::Acquire) {
19949                Err(NodeSecretResolveError::Denied)
19950            } else {
19951                NodeSecretValue::text("fixture-secret")
19952                    .map_err(|_| NodeSecretResolveError::Unavailable)
19953            }
19954        }
19955    }
19956
19957    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19958        fn resolve_child_environment(
19959            &self,
19960        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19961            Ok(vec![EnvMutation {
19962                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19963                value: None,
19964            }])
19965        }
19966    }
19967
19968    fn agent(value: &str) -> AgentId {
19969        AgentId::new(value).unwrap()
19970    }
19971
19972    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19973        let unique = std::time::SystemTime::now()
19974            .duration_since(std::time::UNIX_EPOCH)
19975            .unwrap()
19976            .as_nanos();
19977        std::env::temp_dir().join(format!(
19978            "gate4agent-node-{label}-{}-{unique}",
19979            std::process::id(),
19980        ))
19981    }
19982
19983    fn materialization_test_shared(
19984        deny_secret: bool,
19985    ) -> (
19986        NodeShared,
19987        NativeRuntime,
19988        ResolvedEnvironmentProfileReceipt,
19989        PathBuf,
19990        Arc<AtomicBool>,
19991    ) {
19992        let workspace = WorkspaceConfig::new(
19993            WorkspaceId::new("primary").unwrap(),
19994            std::env::current_dir().unwrap(),
19995        )
19996        .unwrap();
19997        materialization_test_shared_with_workspace(deny_secret, workspace)
19998    }
19999
20000    fn materialization_test_shared_with_workspace(
20001        deny_secret: bool,
20002        workspace: WorkspaceConfig,
20003    ) -> (
20004        NodeShared,
20005        NativeRuntime,
20006        ResolvedEnvironmentProfileReceipt,
20007        PathBuf,
20008        Arc<AtomicBool>,
20009    ) {
20010        let root = temporary_workspace_root(if deny_secret {
20011            "materialization-denied"
20012        } else {
20013            "materialization-ready"
20014        });
20015        std::fs::create_dir_all(&root).unwrap();
20016        let materialization_root = root.join("session-environments");
20017        let deny = Arc::new(AtomicBool::new(deny_secret));
20018        let resolver = Arc::new(FixtureSecretResolver {
20019            deny: Arc::clone(&deny),
20020        });
20021        let materializer = SessionEnvironmentMaterializer::new(
20022            materialization_root,
20023            resolver,
20024        )
20025        .unwrap();
20026        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20027        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20028            "materialized-claude-r1",
20029        )
20030        .unwrap();
20031        let materialization = NodeSessionMaterializationProfile::new(
20032            vec![NodeSessionEnvironmentMutation::SetSecret {
20033                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20034                reference: NodeSecretReference::new("fixture-token").unwrap(),
20035            }],
20036            vec![NodeSessionPathBinding::new(
20037                "GATE4AGENT_PROVIDER_HOME",
20038                NodeSessionPathClass::ProviderHome,
20039            )
20040            .unwrap()],
20041            vec![NodeSessionFile::secret(
20042                NodeSessionPathClass::Config,
20043                "auth/token",
20044                NodeSecretReference::new("fixture-token").unwrap(),
20045            )
20046            .unwrap()],
20047        )
20048        .unwrap();
20049        let catalog = active_registry().unwrap();
20050        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20051        let control = runtime.native_launch_profile_control();
20052        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20053            profile_id.clone(),
20054            profile_revision.clone(),
20055            agent("claude"),
20056            [NativeLaunchProfile::new(
20057                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20058                agent("claude"),
20059                TransportKind::Pty,
20060                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20061                Arc::new(EmptyEnvironmentResolver),
20062            )
20063            .unwrap()],
20064            Some(materialization),
20065        )
20066        .unwrap();
20067        let (binding, native_profiles, materialization) = node_profile.into_parts();
20068        for native_profile in native_profiles {
20069            runtime.upsert_native_launch_profile(native_profile).unwrap();
20070        }
20071        let shared = NodeShared::new_with_incarnation(
20072            handle,
20073            "fixture-token".to_owned(),
20074            NodeId::new("node-materialization-test").unwrap(),
20075            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20076            vec![workspace],
20077            vec![agent("claude")],
20078            ProviderRuntimeStatuses::default(),
20079            None,
20080            Vec::new(),
20081            Vec::new(),
20082            SpawnProfileRegistry::default(),
20083            Some(control),
20084            None,
20085            Some(materializer),
20086            None,
20087            Vec::new(),
20088            Vec::new(),
20089            Vec::new(),
20090            Vec::new(),
20091            None,
20092            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20093        );
20094        shared
20095            .environment_profiles
20096            .write()
20097            .unwrap_or_else(|poisoned| poisoned.into_inner())
20098            .insert(binding.id.clone(), binding);
20099        shared
20100            .environment_materialization_profiles
20101            .write()
20102            .unwrap_or_else(|poisoned| poisoned.into_inner())
20103            .insert(profile_id.clone(), materialization.unwrap());
20104        (
20105            shared,
20106            runtime,
20107            ResolvedEnvironmentProfileReceipt {
20108                profile_id,
20109                profile_revision,
20110                network_allowlist: None,
20111                browser_profile_id: None,
20112            },
20113            root,
20114            deny,
20115        )
20116    }
20117
20118
20119    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20120
20121    async fn characterization_spawn(
20122        shared: &NodeShared,
20123        mode: SessionMode,
20124        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20125        required: &[ProviderRuntimeCapability],
20126        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20127    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20128        shared
20129            .spawn_session_with_deadline(
20130                WorkspaceId::new("primary").unwrap(),
20131                agent("claude"),
20132                mode,
20133                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20134                None,
20135                environment_profile,
20136                None,
20137                None,
20138                None,
20139                Some(ProviderRuntimePolicy::raw_pty()),
20140                SpawnRecordPolicy::ProviderIdentityOnly,
20141                Some(Instant::now() + Duration::from_secs(30)),
20142                required,
20143                harness_mcp,
20144                ApprovalLevel::default(),
20145                None,
20146                None,
20147            )
20148            .await
20149    }
20150
20151    fn assert_no_session_state(shared: &NodeShared) {
20152        assert!(shared.handle.snapshot().sessions.is_empty());
20153        assert!(shared
20154            .session_bindings
20155            .lock()
20156            .unwrap_or_else(|poisoned| poisoned.into_inner())
20157            .is_empty());
20158        assert!(shared
20159            .materializations
20160            .lock()
20161            .unwrap_or_else(|poisoned| poisoned.into_inner())
20162            .is_empty());
20163    }
20164
20165    #[tokio::test]
20166    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20167        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20168        let error = characterization_spawn(
20169            &shared,
20170            SessionMode::Pty,
20171            None,
20172            &[ProviderRuntimeCapability::SemanticReadiness],
20173            None,
20174        )
20175        .await
20176        .unwrap_err();
20177        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20178        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20179        assert_no_session_state(&shared);
20180        drop(shared);
20181        drop(runtime);
20182        std::fs::remove_dir_all(root).unwrap();
20183    }
20184
20185    #[tokio::test]
20186    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20187        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20188        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20189            .await
20190            .unwrap_err();
20191        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20192        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20193        assert_no_session_state(&shared);
20194        drop(shared);
20195        drop(runtime);
20196        std::fs::remove_dir_all(root).unwrap();
20197    }
20198
20199    #[test]
20200    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20201        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20202        let address = SessionAddress {
20203            workspace_id: WorkspaceId::new("primary").unwrap(),
20204            session: SessionKey {
20205                instance_id: AgentInstanceId(77),
20206                generation: SessionGeneration(1),
20207            },
20208        };
20209        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20210        let removed = shared.remove_binding(&address).expect("binding removed");
20211        assert!(removed.environment_profile.is_some());
20212        assert!(shared.remove_binding(&address).is_none());
20213        assert_no_session_state(&shared);
20214        drop(shared);
20215        drop(runtime);
20216        std::fs::remove_dir_all(root).unwrap();
20217    }
20218
20219    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20220        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20221        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20222        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20223            .expect("review current test executable as fixture helper");
20224        let registry = HarnessMcpProxyRegistry::new(helper);
20225        let spec = crate::protocol::SpawnSpec {
20226            target: crate::protocol::SpawnTarget {
20227                node_id: NodeId::new("node-characterization").unwrap(),
20228                workspace_id: WorkspaceId::new("primary").unwrap(),
20229                worktree_id: None,
20230            },
20231            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20232            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20233            overrides: crate::protocol::SpawnOverrides::default(),
20234            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20235            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20236            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20237        };
20238        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20239        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20240        let launch = HarnessMcpLaunchV1 {
20241            server_name: "fixture-harness".to_owned(),
20242            args: vec!["--session-proxy".to_owned()],
20243            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20244            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20245            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20246            trace: None,
20247            scrub_env: Vec::new(),
20248        };
20249        let expires = unix_time_ms().saturating_add(30_000);
20250        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20251        registry
20252            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20253            .unwrap()
20254    }
20255
20256    #[tokio::test]
20257    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20258        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20259        let prepared = characterization_prepared_harness("claude").await;
20260        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20261            .await
20262            .unwrap_err();
20263        // Inline is stopped by runtime admission before the harness check runs.
20264        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20265        let mismatched = characterization_prepared_harness("codex").await;
20266        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20267            .await
20268            .unwrap_err();
20269        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20270        assert_no_session_state(&shared);
20271        drop(shared);
20272        drop(runtime);
20273        std::fs::remove_dir_all(root).unwrap();
20274    }
20275
20276    #[tokio::test]
20277    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20278        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20279        shared.native_launch_profile_control = None;
20280        let prepared = characterization_prepared_harness("claude").await;
20281        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20282            .await
20283            .unwrap_err();
20284        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20285        assert_no_session_state(&shared);
20286        drop(shared);
20287        drop(runtime);
20288        std::fs::remove_dir_all(root).unwrap();
20289    }
20290}
20291
20292#[cfg(all(test, windows))]
20293mod tests {
20294    use super::*;
20295    use gate4agent_types::ProviderActivity;
20296    use crate::session_environment::{
20297        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20298        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20299        NodeSessionPathClass,
20300    };
20301    use gate4agent_catalog::EnvMutation;
20302    use gate4agent_runtime_native::{
20303        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20304        NativeLaunchProfile, NativeLaunchProfileId,
20305    };
20306    use std::ffi::OsString;
20307    use std::sync::Arc;
20308
20309    struct EmptyEnvironmentResolver;
20310
20311    struct FixtureSecretResolver {
20312        deny: Arc<AtomicBool>,
20313    }
20314
20315    impl NodeSecretResolver for FixtureSecretResolver {
20316        fn resolve(
20317            &self,
20318            _reference: &NodeSecretReference,
20319        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20320            if self.deny.load(Ordering::Acquire) {
20321                Err(NodeSecretResolveError::Denied)
20322            } else {
20323                NodeSecretValue::text("fixture-secret")
20324                    .map_err(|_| NodeSecretResolveError::Unavailable)
20325            }
20326        }
20327    }
20328
20329    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20330        fn resolve_child_environment(
20331            &self,
20332        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20333            Ok(vec![EnvMutation {
20334                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20335                value: None,
20336            }])
20337        }
20338    }
20339
20340    fn agent(value: &str) -> AgentId {
20341        AgentId::new(value).unwrap()
20342    }
20343
20344    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20345        let catalog = active_registry().unwrap();
20346        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20347        let workspace = WorkspaceConfig::new(
20348            WorkspaceId::new("primary").unwrap(),
20349            std::env::current_dir().unwrap(),
20350        )
20351        .unwrap()
20352        .with_worktree_service_mode(mode);
20353        NodeShared::new(
20354            handle,
20355            "fixture-token".to_owned(),
20356            NodeId::new("node-terminal-test").unwrap(),
20357            vec![workspace],
20358            vec![agent("claude")],
20359        )
20360    }
20361
20362    fn terminal_test_shared() -> NodeShared {
20363        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20364    }
20365
20366    #[cfg(feature = "fixture")]
20367    #[test]
20368    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20369        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20370        assert_eq!(probe.latest_code(), None);
20371
20372        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20373        assert_eq!(
20374            probe.latest_code(),
20375            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20376        );
20377
20378        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20379        assert_eq!(
20380            probe.latest_code(),
20381            Some(NodeFailureCode::SpawnDeadlineExceeded),
20382        );
20383
20384        probe.record(None);
20385        assert_eq!(probe.latest_code(), None);
20386    }
20387
20388    #[test]
20389    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20390        let source = temporary_workspace_root("launch-inventory-source");
20391        let allocation = temporary_workspace_root("launch-inventory-allocation");
20392        std::fs::create_dir_all(&source).unwrap();
20393        std::fs::create_dir_all(&allocation).unwrap();
20394        let workspace_id = WorkspaceId::new("primary").unwrap();
20395        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20396            .unwrap()
20397            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20398        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20399            workspace = workspace
20400                .with_managed_worktree_profile(
20401                    ManagedWorktreeProfile::new(
20402                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20403                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20404                        &allocation,
20405                        "gate4agent",
20406                        "HEAD",
20407                        ManagedWorktreeRetention::RemoveWhenReleased,
20408                    )
20409                    .unwrap(),
20410                )
20411                .unwrap();
20412        }
20413        let overflow = workspace.clone().with_managed_worktree_profile(
20414            ManagedWorktreeProfile::new(
20415                WorktreeProfileId::new("overflow").unwrap(),
20416                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20417                &allocation,
20418                "gate4agent",
20419                "HEAD",
20420                ManagedWorktreeRetention::Retain,
20421            )
20422            .unwrap(),
20423        );
20424        assert!(matches!(
20425            overflow,
20426            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20427                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20428                ..
20429            }),
20430        ));
20431
20432        let catalog = active_registry().unwrap();
20433        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20434        let shared = NodeShared::new(
20435            handle,
20436            "fixture-token".to_owned(),
20437            NodeId::new("node-launch-inventory").unwrap(),
20438            vec![workspace],
20439            vec![agent("claude")],
20440        );
20441        let snapshot = shared.snapshot();
20442        let launch = snapshot.launch_inventory.as_ref().unwrap();
20443        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20444        assert!(launch.bundles.as_ref().unwrap().is_empty());
20445        let profiles = snapshot.workspaces[0]
20446            .managed_worktree_profiles
20447            .as_ref()
20448            .unwrap();
20449        assert_eq!(
20450            snapshot.workspaces[0].worktree_service_mode,
20451            Some(WorktreeServiceMode::Managed),
20452        );
20453        assert_eq!(
20454            profiles.profiles.len(),
20455            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20456        );
20457        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20458        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20459            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20460        }
20461        let legacy = snapshot_for_wire(
20462            &shared,
20463            true,
20464            true,
20465            false,
20466            false,
20467            true,
20468            false,
20469            true,
20470            false,
20471            true,
20472        );
20473        assert!(legacy.launch_inventory.is_none());
20474        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20475
20476        drop(shared);
20477        std::fs::remove_dir_all(source).unwrap();
20478        std::fs::remove_dir_all(allocation).unwrap();
20479    }
20480
20481    #[test]
20482    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20483        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20484            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20485        }));
20486        let source = gate4agent_types::ProviderSource {
20487            family: AdapterFamily::PtySemantic,
20488            binding: AdapterBinding::new(
20489                gate4agent_types::AdapterId::new("claude").unwrap(),
20490                "fixture/v1",
20491                gate4agent_types::AdapterVerification::SyntheticFixture,
20492            )
20493            .unwrap(),
20494        };
20495        let provider = ProviderSnapshot {
20496            sequence: 17,
20497            session: Some(ProviderSessionIdentity {
20498                key: ProviderSessionKey::SessionId,
20499                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20500                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20501            }),
20502            model: Some("PRIVATE_MODEL".to_owned()),
20503            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20504            completed_turns: 3,
20505            usage: gate4agent_types::TokenUsage {
20506                input_tokens: 10,
20507                output_tokens: 20,
20508                cache_read_tokens: 30,
20509                cache_write_tokens: 40,
20510                reasoning_tokens: 50,
20511                context_window: Some(200_000),
20512            },
20513            lead_activity: ProviderActivity::WaitingForInput,
20514            activity: ProviderActivity::WaitingForInput,
20515            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20516            active_tools: (0..9)
20517                .map(|index| gate4agent_types::ActiveProviderTool {
20518                    id: format!("private-tool-id-{index}"),
20519                    name: if index == 0 {
20520                        format!("  Read\u{0000}{}", "x".repeat(80))
20521                    } else {
20522                        format!("Tool-{index}")
20523                    },
20524                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20525                })
20526                .collect(),
20527            interactions: vec![gate4agent_types::ProviderInteraction {
20528                id: gate4agent_types::ProviderInteractionId(17),
20529                source: source.clone(),
20530                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20531                interaction_kind: ProviderInteractionKind::Approval,
20532                tool_name: " Approve\u{0007}Tool ".to_owned(),
20533                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20534                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20535                resume_lead_activity: Some(ProviderActivity::Working),
20536                status: ProviderInteractionStatus::Pending,
20537            }],
20538            subagents: vec![gate4agent_types::ProviderSubagent {
20539                source: source.clone(),
20540                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20541                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20542                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20543            }],
20544            sources: vec![gate4agent_types::ProviderSourceCursor {
20545                source,
20546                sequence: 17,
20547                gap_count: 2,
20548                stale: true,
20549            }],
20550            last_event: Some(ProviderEvent::Error {
20551                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20552            }),
20553            gap_count: 2,
20554            stale: true,
20555        };
20556        let address = terminal_address(3);
20557        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20558        assert_eq!(entry.address, address);
20559        assert_eq!(entry.progress.provider_sequence, 17);
20560        assert_eq!(entry.progress.active_tool_count, 9);
20561        assert_eq!(
20562            entry.progress.active_tool_labels.len(),
20563            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20564        );
20565        assert_eq!(entry.progress.subagent_count, 1);
20566        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20567        assert!(entry.progress.truncated);
20568        let encoded = serde_json::to_string(&entry).unwrap();
20569        for forbidden in [
20570            "PRIVATE_PROVIDER_SESSION",
20571            "PRIVATE_PROVIDER_PATH",
20572            "PRIVATE_MODEL",
20573            "PRIVATE_TOOL_INVENTORY",
20574            "PRIVATE_CURRENT_PROMPT",
20575            "PRIVATE_TOOL_INPUT_JSON",
20576            "PRIVATE_PROVIDER_REQUEST",
20577            "PRIVATE_INTERACTION_PROMPT",
20578            "PRIVATE_INTERACTION_AGENT",
20579            "PRIVATE_SUBAGENT_ID",
20580            "PRIVATE_SUBAGENT_TYPE",
20581            "PRIVATE_SUBAGENT_DESCRIPTION",
20582            "PRIVATE_PROVIDER_ERROR",
20583            "input_json",
20584            "current_prompt",
20585            "session_id",
20586        ] {
20587            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20588        }
20589
20590        let snapshot = NodeSnapshot {
20591            node_id: NodeId::new("node-progress-test").unwrap(),
20592            enabled_providers: vec![agent("claude")],
20593            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20594            workspaces: Vec::new(),
20595            session_records: Vec::new(),
20596            managed_worktrees: Vec::new(),
20597            launch_inventory: None,
20598            agent_progress: vec![entry],
20599        };
20600        let mut legacy_reply = ResponseEnvelope {
20601            request_id: 1,
20602            result: Ok(NodeResponse::Snapshot {
20603                event_sequence: 0,
20604                controller: None,
20605                snapshot: snapshot.clone(),
20606            }),
20607        };
20608        project_response_without_agent_progress(&mut legacy_reply);
20609        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20610            panic!("agent progress projection changed response kind");
20611        };
20612        assert!(legacy.agent_progress.is_empty());
20613        assert_eq!(snapshot.agent_progress.len(), 1);
20614    }
20615
20616    fn terminal_address(generation: u64) -> SessionAddress {
20617        SessionAddress {
20618            workspace_id: WorkspaceId::new("primary").unwrap(),
20619            session: SessionKey {
20620                instance_id: AgentInstanceId(41),
20621                generation: SessionGeneration(generation),
20622            },
20623        }
20624    }
20625
20626    fn spawn_spec_fixture() -> SpawnSpec {
20627        SpawnSpec {
20628            target: crate::protocol::SpawnTarget {
20629                node_id: NodeId::new("node-terminal-test").unwrap(),
20630                workspace_id: WorkspaceId::new("primary").unwrap(),
20631                worktree_id: None,
20632            },
20633            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20634            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20635                .unwrap(),
20636            overrides: crate::protocol::SpawnOverrides::default(),
20637            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20638            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20639            required_capabilities: SpawnRequiredCapabilities::default(),
20640        }
20641    }
20642
20643    fn environment_profile_test_shared(
20644    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20645        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20646        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20647            "local-claude-r1",
20648        )
20649        .unwrap();
20650        let spawn_profiles = SpawnProfileRegistry::new([
20651            crate::protocol::SpawnProfileDefaults {
20652                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20653                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20654                provider: agent("claude"),
20655                mode: SessionMode::Pty,
20656                terminal_size: gate4agent_types::TerminalSize {
20657                    rows: 24,
20658                    columns: 80,
20659                },
20660                prompt: None,
20661                bundle_id: None,
20662                context_id: None,
20663                environment_profile_id: Some(profile_id.clone()),
20664            },
20665        ])
20666        .unwrap();
20667        let catalog = active_registry().unwrap();
20668        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20669        let control = runtime.native_launch_profile_control();
20670        let node_profile = NodeEnvironmentProfile::new(
20671            profile_id.clone(),
20672            profile_revision.clone(),
20673            agent("claude"),
20674            [NativeLaunchProfile::new(
20675                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20676                agent("claude"),
20677                TransportKind::Pty,
20678                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20679                Arc::new(EmptyEnvironmentResolver),
20680            )
20681            .unwrap()],
20682        )
20683        .unwrap();
20684        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20685        for native_profile in native_profiles {
20686            runtime.upsert_native_launch_profile(native_profile).unwrap();
20687        }
20688        let workspace = WorkspaceConfig::new(
20689            WorkspaceId::new("primary").unwrap(),
20690            std::env::current_dir().unwrap(),
20691        )
20692        .unwrap();
20693        let shared = NodeShared::new_with_incarnation(
20694            handle,
20695            "fixture-token".to_owned(),
20696            NodeId::new("node-terminal-test").unwrap(),
20697            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20698            vec![workspace],
20699            vec![agent("claude")],
20700            ProviderRuntimeStatuses::default(),
20701            None,
20702            Vec::new(),
20703            Vec::new(),
20704            spawn_profiles,
20705            Some(control),
20706            None,
20707            None,
20708            None,
20709            Vec::new(),
20710            Vec::new(),
20711            Vec::new(),
20712            Vec::new(),
20713            None,
20714            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20715        );
20716        shared
20717            .environment_profiles
20718            .write()
20719            .unwrap_or_else(|poisoned| poisoned.into_inner())
20720            .insert(binding.id.clone(), binding);
20721        (
20722            shared,
20723            runtime,
20724            ResolvedEnvironmentProfileReceipt {
20725                profile_id,
20726                profile_revision,
20727                network_allowlist: None,
20728                browser_profile_id: None,
20729            },
20730        )
20731    }
20732
20733    fn materialization_test_shared(
20734        deny_secret: bool,
20735    ) -> (
20736        NodeShared,
20737        NativeRuntime,
20738        ResolvedEnvironmentProfileReceipt,
20739        PathBuf,
20740        Arc<AtomicBool>,
20741    ) {
20742        let workspace = WorkspaceConfig::new(
20743            WorkspaceId::new("primary").unwrap(),
20744            std::env::current_dir().unwrap(),
20745        )
20746        .unwrap();
20747        materialization_test_shared_with_workspace(deny_secret, workspace)
20748    }
20749
20750    fn materialization_test_shared_with_workspace(
20751        deny_secret: bool,
20752        workspace: WorkspaceConfig,
20753    ) -> (
20754        NodeShared,
20755        NativeRuntime,
20756        ResolvedEnvironmentProfileReceipt,
20757        PathBuf,
20758        Arc<AtomicBool>,
20759    ) {
20760        let root = temporary_workspace_root(if deny_secret {
20761            "materialization-denied"
20762        } else {
20763            "materialization-ready"
20764        });
20765        std::fs::create_dir_all(&root).unwrap();
20766        let materialization_root = root.join("session-environments");
20767        let deny = Arc::new(AtomicBool::new(deny_secret));
20768        let resolver = Arc::new(FixtureSecretResolver {
20769            deny: Arc::clone(&deny),
20770        });
20771        let materializer = SessionEnvironmentMaterializer::new(
20772            materialization_root,
20773            resolver,
20774        )
20775        .unwrap();
20776        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20777        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20778            "materialized-claude-r1",
20779        )
20780        .unwrap();
20781        let materialization = NodeSessionMaterializationProfile::new(
20782            vec![NodeSessionEnvironmentMutation::SetSecret {
20783                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20784                reference: NodeSecretReference::new("fixture-token").unwrap(),
20785            }],
20786            vec![NodeSessionPathBinding::new(
20787                "GATE4AGENT_PROVIDER_HOME",
20788                NodeSessionPathClass::ProviderHome,
20789            )
20790            .unwrap()],
20791            vec![NodeSessionFile::secret(
20792                NodeSessionPathClass::Config,
20793                "auth/token",
20794                NodeSecretReference::new("fixture-token").unwrap(),
20795            )
20796            .unwrap()],
20797        )
20798        .unwrap();
20799        let catalog = active_registry().unwrap();
20800        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20801        let control = runtime.native_launch_profile_control();
20802        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20803            profile_id.clone(),
20804            profile_revision.clone(),
20805            agent("claude"),
20806            [NativeLaunchProfile::new(
20807                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20808                agent("claude"),
20809                TransportKind::Pty,
20810                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20811                Arc::new(EmptyEnvironmentResolver),
20812            )
20813            .unwrap()],
20814            Some(materialization),
20815        )
20816        .unwrap();
20817        let (binding, native_profiles, materialization) = node_profile.into_parts();
20818        for native_profile in native_profiles {
20819            runtime.upsert_native_launch_profile(native_profile).unwrap();
20820        }
20821        let shared = NodeShared::new_with_incarnation(
20822            handle,
20823            "fixture-token".to_owned(),
20824            NodeId::new("node-materialization-test").unwrap(),
20825            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20826            vec![workspace],
20827            vec![agent("claude")],
20828            ProviderRuntimeStatuses::default(),
20829            None,
20830            Vec::new(),
20831            Vec::new(),
20832            SpawnProfileRegistry::default(),
20833            Some(control),
20834            None,
20835            Some(materializer),
20836            None,
20837            Vec::new(),
20838            Vec::new(),
20839            Vec::new(),
20840            Vec::new(),
20841            None,
20842            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20843        );
20844        shared
20845            .environment_profiles
20846            .write()
20847            .unwrap_or_else(|poisoned| poisoned.into_inner())
20848            .insert(binding.id.clone(), binding);
20849        shared
20850            .environment_materialization_profiles
20851            .write()
20852            .unwrap_or_else(|poisoned| poisoned.into_inner())
20853            .insert(profile_id.clone(), materialization.unwrap());
20854        (
20855            shared,
20856            runtime,
20857            ResolvedEnvironmentProfileReceipt {
20858                profile_id,
20859                profile_revision,
20860                network_allowlist: None,
20861                browser_profile_id: None,
20862            },
20863            root,
20864            deny,
20865        )
20866    }
20867
20868    #[cfg(feature = "fixture")]
20869    #[derive(Clone, Copy)]
20870    enum CodexHomeProfile {
20871        Missing,
20872        WrongClass,
20873        Exact,
20874    }
20875
20876    #[cfg(feature = "fixture")]
20877    fn codex_bundle_test_shared(
20878        home_profile: CodexHomeProfile,
20879    ) -> (
20880        NodeShared,
20881        NativeRuntime,
20882        ResolvedEnvironmentProfileReceipt,
20883        NodeBundle,
20884        PathBuf,
20885    ) {
20886        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20887        const CLAUDE_MANIFEST: &[u8] =
20888            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20889        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20890        const DIGEST: &str =
20891            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20892
20893        let (shared, mut runtime, _claude_receipt, root, _deny) =
20894            materialization_test_shared(false);
20895        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20896            CodexHomeProfile::Missing => "codex-home-missing",
20897            CodexHomeProfile::WrongClass => "codex-home-wrong",
20898            CodexHomeProfile::Exact => "codex-home-exact",
20899        })
20900        .unwrap();
20901        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20902            "codex-home-r1",
20903        )
20904        .unwrap();
20905        let materialization = match home_profile {
20906            CodexHomeProfile::Missing => None,
20907            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20908                Some(NodeSessionMaterializationProfile::new(
20909                    Vec::new(),
20910                    vec![NodeSessionPathBinding::new(
20911                        "CODEX_HOME",
20912                        if matches!(home_profile, CodexHomeProfile::Exact) {
20913                            NodeSessionPathClass::ProviderHome
20914                        } else {
20915                            NodeSessionPathClass::Config
20916                        },
20917                    )
20918                    .unwrap()],
20919                    Vec::new(),
20920                )
20921                .unwrap())
20922            }
20923        };
20924        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20925            profile_id.clone(),
20926            profile_revision.clone(),
20927            agent("codex"),
20928            [NativeLaunchProfile::new(
20929                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20930                agent("codex"),
20931                TransportKind::Pty,
20932                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20933                Arc::new(EmptyEnvironmentResolver),
20934            )
20935            .unwrap()],
20936            materialization,
20937        )
20938        .unwrap();
20939        let (binding, native_profiles, materialization) = node_profile.into_parts();
20940        for native_profile in native_profiles {
20941            runtime.upsert_native_launch_profile(native_profile).unwrap();
20942        }
20943        shared
20944            .environment_profiles
20945            .write()
20946            .unwrap_or_else(|poisoned| poisoned.into_inner())
20947            .insert(binding.id.clone(), binding);
20948        if let Some(materialization) = materialization {
20949            shared
20950                .environment_materialization_profiles
20951                .write()
20952                .unwrap_or_else(|poisoned| poisoned.into_inner())
20953                .insert(profile_id.clone(), materialization);
20954        }
20955
20956        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20957        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20958        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20959        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20960        std::fs::write(
20961            bundle_source.join(".claude-plugin/plugin.json"),
20962            CLAUDE_MANIFEST,
20963        )
20964        .unwrap();
20965        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20966        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20967        let bundle = NodeBundle::new(
20968            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20969            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20970            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20971            &bundle_source,
20972        )
20973        .unwrap();
20974        *shared
20975            .bundle_catalog
20976            .write()
20977            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20978            BundleCatalog::new([bundle.clone()]).unwrap();
20979        (
20980            shared,
20981            runtime,
20982            ResolvedEnvironmentProfileReceipt {
20983                profile_id,
20984                profile_revision,
20985                network_allowlist: None,
20986                browser_profile_id: None,
20987            },
20988            bundle,
20989            root,
20990        )
20991    }
20992
20993    #[cfg(feature = "fixture")]
20994    #[test]
20995    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20996        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20997            let (shared, runtime, receipt, bundle, root) =
20998                codex_bundle_test_shared(home_profile);
20999            let error = shared
21000                .bundle_layout(
21001                    &agent("codex"),
21002                    SessionMode::Pty,
21003                    Some(&receipt),
21004                    &bundle,
21005                )
21006                .unwrap_err();
21007            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21008            assert!(shared
21009                .materializations
21010                .lock()
21011                .unwrap_or_else(|poisoned| poisoned.into_inner())
21012                .is_empty());
21013            let materialization_root = root.join("session-environments");
21014            assert!(!std::fs::read_dir(&materialization_root)
21015                .unwrap()
21016                .filter_map(Result::ok)
21017                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21018            drop(shared);
21019            drop(runtime);
21020            let _ = std::fs::remove_dir_all(root);
21021        }
21022    }
21023
21024    #[cfg(feature = "fixture")]
21025    #[test]
21026    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21027        let (shared, runtime, receipt, bundle, root) =
21028            codex_bundle_test_shared(CodexHomeProfile::Exact);
21029        let address = SessionAddress {
21030            workspace_id: WorkspaceId::new("primary").unwrap(),
21031            session: SessionKey {
21032                instance_id: AgentInstanceId(310),
21033                generation: SessionGeneration(1),
21034            },
21035        };
21036        let (overlay, guard) = shared
21037            .prepare_session_materialization(
21038                &address,
21039                &agent("codex"),
21040                SessionMode::Pty,
21041                Some(&receipt),
21042                Some(&bundle.receipt()),
21043                None,
21044                None,
21045            )
21046            .unwrap()
21047            .unwrap();
21048        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21049        let ownership = shared
21050            .materializations
21051            .lock()
21052            .unwrap_or_else(|poisoned| poisoned.into_inner())
21053            .get(guard.id().unwrap())
21054            .unwrap()
21055            .clone();
21056        let (profile, _, _) = shared
21057            .materialization_profile(
21058                &agent("codex"),
21059                SessionMode::Pty,
21060                Some(&receipt),
21061                Some(&bundle.receipt()),
21062                None,
21063            )
21064            .unwrap()
21065            .unwrap();
21066        let environment = shared
21067            .session_environment_materializer
21068            .as_ref()
21069            .unwrap()
21070            .resolve_environment(&ownership, &profile)
21071            .unwrap();
21072        assert!(environment.iter().any(|mutation| {
21073            mutation.key == OsString::from("CODEX_HOME")
21074                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21075        }));
21076        assert_eq!(
21077            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21078                .unwrap(),
21079            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21080        );
21081        assert!(std::fs::read_dir(ownership.bundle_root())
21082            .unwrap()
21083            .next()
21084            .is_none());
21085        drop(guard);
21086        drop(shared);
21087        drop(runtime);
21088        let _ = std::fs::remove_dir_all(root);
21089    }
21090
21091    #[cfg(feature = "fixture")]
21092    #[test]
21093    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21094        let (shared, runtime, receipt, bundle, root) =
21095            codex_bundle_test_shared(CodexHomeProfile::Exact);
21096        let bundle_receipt = bundle.receipt();
21097        let address = SessionAddress {
21098            workspace_id: WorkspaceId::new("primary").unwrap(),
21099            session: SessionKey {
21100                instance_id: AgentInstanceId(311),
21101                generation: SessionGeneration(1),
21102            },
21103        };
21104        let (_overlay, guard) = shared
21105            .prepare_session_materialization(
21106                &address,
21107                &agent("codex"),
21108                SessionMode::Pty,
21109                Some(&receipt),
21110                Some(&bundle_receipt),
21111                None,
21112                None,
21113            )
21114            .unwrap()
21115            .unwrap();
21116        let materialization_id = guard.id().unwrap().clone();
21117        let skill_path = shared
21118            .materializations
21119            .lock()
21120            .unwrap_or_else(|poisoned| poisoned.into_inner())
21121            .get(&materialization_id)
21122            .unwrap()
21123            .provider_home()
21124            .join("skills/review-code/SKILL.md");
21125        let record_id = shared
21126            .bind_spawn_session_with_materialization(
21127                &address,
21128                agent("codex"),
21129                SessionMode::Pty,
21130                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21131                SpawnRecordPolicy::ProviderIdentityOnly,
21132                Some(receipt.clone()),
21133                Some(bundle_receipt.clone()),
21134                None,
21135                Some(materialization_id.clone()),
21136            )
21137            .unwrap()
21138            .unwrap();
21139        guard.retain();
21140        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21141
21142        let error = match shared.resolve_record_materialization(
21143                &record_id,
21144                &agent("codex"),
21145                SessionMode::Pty,
21146                Some(&receipt),
21147                Some(&bundle_receipt),
21148                None,
21149            ) {
21150                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21151                Err(error) => error,
21152            };
21153        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21154        assert_eq!(
21155            shared
21156                .materializations
21157                .lock()
21158                .unwrap_or_else(|poisoned| poisoned.into_inner())
21159                .get(&materialization_id)
21160                .unwrap()
21161                .state(),
21162            MaterializationState::RecoveryRequired,
21163        );
21164        drop(shared);
21165        drop(runtime);
21166        let _ = std::fs::remove_dir_all(root);
21167    }
21168
21169    fn bind_record_owned_materialization_fixture(
21170        shared: &NodeShared,
21171        receipt: &ResolvedEnvironmentProfileReceipt,
21172        instance_id: u64,
21173    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21174        let address = SessionAddress {
21175            workspace_id: WorkspaceId::new("primary").unwrap(),
21176            session: SessionKey {
21177                instance_id: AgentInstanceId(instance_id),
21178                generation: SessionGeneration(1),
21179            },
21180        };
21181        let (overlay, materialization_guard) = shared
21182            .prepare_session_materialization(
21183                &address,
21184                &agent("claude"),
21185                SessionMode::Pty,
21186                Some(receipt),
21187                None,
21188                None,
21189                None,
21190            )
21191            .unwrap()
21192            .unwrap();
21193        let materialization_id = materialization_guard.id().unwrap().clone();
21194        let materialization_root = shared
21195            .materializations
21196            .lock()
21197            .unwrap_or_else(|poisoned| poisoned.into_inner())
21198            .get(&materialization_id)
21199            .unwrap()
21200            .root()
21201            .to_path_buf();
21202        let selection = shared
21203            .select_environment_profile(
21204                address.session.instance_id,
21205                &agent("claude"),
21206                SessionMode::Pty,
21207                Some(receipt),
21208            )
21209            .unwrap()
21210            .unwrap();
21211        assert!(shared
21212            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21213            .unwrap()
21214            .is_none());
21215        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21216        let record_id = shared
21217            .bind_spawn_session_with_materialization(
21218                &address,
21219                agent("claude"),
21220                SessionMode::Pty,
21221                policy,
21222                SpawnRecordPolicy::ProviderIdentityOnly,
21223                Some(receipt.clone()),
21224                None,
21225                None,
21226                Some(materialization_id.clone()),
21227            )
21228            .unwrap()
21229            .unwrap();
21230        selection.retain();
21231        materialization_guard.retain();
21232        (address, record_id, materialization_id, materialization_root)
21233    }
21234
21235    #[test]
21236    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21237        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21238        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21239            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21240        }));
21241        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21242        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21243        let mut spec = spawn_spec_fixture();
21244        spec.expected_profile_revision =
21245            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21246        assert!(matches!(
21247            &spec.overrides.environment_profile_id,
21248            crate::protocol::SpawnOverride::Inherit,
21249        ));
21250        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21251            .requires_child_environment_profile_capability());
21252        assert!(request_requires_child_environment_profile(
21253            &shared,
21254            &NodeRequest::SpawnSpec { spec: spec.clone() },
21255        ));
21256
21257        let address = terminal_address(1);
21258        shared.bind_session_with_policy(
21259            &address,
21260            ProviderRuntimePolicy::raw_pty(),
21261            Some(environment_profile.clone()),
21262        );
21263        assert!(request_requires_child_environment_profile(
21264            &shared,
21265            &NodeRequest::Input {
21266                session: address.clone(),
21267                text: "x".to_owned(),
21268            },
21269        ));
21270
21271        let mut record = record("claude", "profile-record");
21272        record.active_session = Some(address.clone());
21273        record.environment_profile = Some(environment_profile.clone());
21274        let record_id = record.record_id.clone();
21275        shared.insert_record(record.clone()).unwrap();
21276        assert!(request_requires_child_environment_profile(
21277            &shared,
21278            &NodeRequest::ResumeSessionRecord {
21279                record_id,
21280                terminal_size: gate4agent_types::TerminalSize {
21281                    rows: 24,
21282                    columns: 80,
21283                },
21284                initial_prompt: None,
21285            },
21286        ));
21287
21288        let snapshot = snapshot_for_wire(
21289            &shared, false, true, true, true, false, false, false, false, true,
21290        );
21291        assert_eq!(snapshot.session_records.len(), 1);
21292        assert!(snapshot.session_records[0].environment_profile.is_none());
21293        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21294            sequence: 1,
21295            event: NodeEvent::SessionRecordUpserted { record },
21296        });
21297        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21298            panic!("record projection changed the event kind");
21299        };
21300        assert!(record.environment_profile.is_none());
21301
21302        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21303        let mut reply = ResponseEnvelope {
21304            request_id: 1,
21305            result: Ok(NodeResponse::SpawnSpecAccepted {
21306                receipt: resolved.receipt_with_environment(
21307                    shared.incarnation_id,
21308                    address,
21309                    Some(environment_profile),
21310                ),
21311            }),
21312        };
21313        project_response_without_child_environment_profile(&mut reply);
21314        assert_eq!(
21315            reply.result.unwrap_err().code,
21316            NodeFailureCode::UnsupportedCapability,
21317        );
21318    }
21319
21320    #[test]
21321    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21322        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21323        let snapshot = shared.snapshot();
21324        let profiles = snapshot
21325            .launch_inventory
21326            .as_ref()
21327            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21328            .unwrap();
21329        assert_eq!(profiles.len(), 1);
21330        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21331        assert!(snapshot.requires_child_environment_profile_capability());
21332
21333        let mut legacy = snapshot.clone();
21334        clear_snapshot_child_environment_profiles(&mut legacy);
21335        assert!(legacy
21336            .launch_inventory
21337            .as_ref()
21338            .unwrap()
21339            .spawn_profiles
21340            .as_ref()
21341            .unwrap()[0]
21342            .environment_profile
21343            .is_none());
21344        assert!(!legacy.requires_child_environment_profile_capability());
21345
21346        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21347        let bindings = shared
21348            .environment_profiles
21349            .read()
21350            .unwrap_or_else(|poisoned| poisoned.into_inner())
21351            .clone();
21352        let mut provider_mismatch = bindings.clone();
21353        provider_mismatch
21354            .values_mut()
21355            .next()
21356            .unwrap()
21357            .provider = agent("codex");
21358        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21359        let mut mode_mismatch = profile;
21360        mode_mismatch.mode = SessionMode::Inline;
21361        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21362
21363        shared
21364            .environment_profiles
21365            .write()
21366            .unwrap_or_else(|poisoned| poisoned.into_inner())
21367            .clear();
21368        assert!(shared
21369            .snapshot()
21370            .launch_inventory
21371            .unwrap()
21372            .spawn_profiles
21373            .unwrap()
21374            .is_empty());
21375    }
21376
21377    #[test]
21378    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21379        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21380            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21381        }));
21382        let shared = terminal_test_shared();
21383        let bundle = ResolvedBundleReceipt {
21384            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21385            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21386            digest: crate::protocol::SpawnBundleDigest::new(format!(
21387                "sha256:{}",
21388                "0".repeat(64),
21389            ))
21390            .unwrap(),
21391        };
21392        let mut bundled = record("claude", "bundle-record");
21393        bundled.bundle = Some(bundle.clone());
21394        let record_id = bundled.record_id.clone();
21395        shared.insert_record(bundled.clone()).unwrap();
21396        assert!(request_requires_session_bundle(
21397            &shared,
21398            &NodeRequest::ForgetSessionRecord {
21399                record_id: record_id.clone(),
21400            },
21401        ));
21402
21403        let snapshot = snapshot_for_wire(
21404            &shared, false, true, true, true, true, false, false, false, true,
21405        );
21406        assert!(snapshot.session_records[0].bundle.is_none());
21407        let launch = snapshot.launch_inventory.as_ref().unwrap();
21408        assert!(launch.spawn_profiles.is_some());
21409        assert!(launch.bundles.is_none());
21410        let event = project_event_without_session_bundle(NodeEventEnvelope {
21411            sequence: 1,
21412            event: NodeEvent::SessionRecordUpserted { record: bundled },
21413        });
21414        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21415            panic!("record projection changed the event kind");
21416        };
21417        assert!(record.bundle.is_none());
21418
21419        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21420        let mut reply = ResponseEnvelope {
21421            request_id: 1,
21422            result: Ok(NodeResponse::SpawnSpecAccepted {
21423                receipt: resolved.receipt_with_materialization(
21424                    shared.incarnation_id,
21425                    terminal_address(17),
21426                    None,
21427                    Some(bundle),
21428                    None,
21429                ),
21430            }),
21431        };
21432        project_response_without_session_bundle(&mut reply);
21433        assert_eq!(
21434            reply.result.unwrap_err().code,
21435            NodeFailureCode::UnsupportedCapability,
21436        );
21437    }
21438
21439    #[test]
21440    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21441        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21442            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21443        }));
21444        let context_id = SpawnContextId::new("context-review").unwrap();
21445        let context = ResolvedContextPackReceipt {
21446            id: context_id.clone(),
21447            digest: crate::protocol::SpawnContextDigest::new(format!(
21448                "sha256:{}",
21449                "c".repeat(64),
21450            ))
21451            .unwrap(),
21452            lineage: ContextPackLineageReceipt {
21453                source_node_id: NodeId::new("node-source").unwrap(),
21454                source_session: terminal_address(1),
21455                source_provider: agent("kimi"),
21456            },
21457            source_message_count: 2,
21458            retained_message_count: 2,
21459            byte_len: 256,
21460            truncated: false,
21461        };
21462        assert!(context.is_valid());
21463        let mut bound_record = record("codex", "context-record");
21464        bound_record.context_id = Some(context_id);
21465        bound_record.context = Some(context.clone());
21466        let session = gate4agent_types::SessionSnapshot {
21467            instance_id: AgentInstanceId(91),
21468            agent_id: agent("kimi"),
21469            transport: TransportKind::Pty,
21470            generation: SessionGeneration(3),
21471            status: SessionStatus::Running,
21472            pending_operation: None,
21473            pending_input: None,
21474            process_id: Some(9001),
21475            terminal_size: None,
21476            terminal_frame: None,
21477            terminal_stale: None,
21478            session_options: None,
21479            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21480            history: gate4agent_types::HistorySnapshot {
21481                pending: None,
21482                candidates: vec![HistoryCandidateSummary {
21483                    id: "candidate-opaque".to_owned(),
21484                    session_id_hint: "session-hint".to_owned(),
21485                    modified_at_unix_ms: Some(1),
21486                }],
21487                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21488                loaded: Some(HistorySessionRecord {
21489                    session_id: "private-vendor-session".to_owned(),
21490                    title: Some("private title".to_owned()),
21491                    cwd: Some(r"C:\private\history-root".to_owned()),
21492                    model: Some("private-model".to_owned()),
21493                    message_count: 2,
21494                    completed_turn_count: None,
21495                    total_tokens: 19,
21496                    messages: vec![
21497                        gate4agent_types::HistoryMessageRecord {
21498                            role: gate4agent_types::HistoryMessageRole::User,
21499                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21500                        },
21501                        gate4agent_types::HistoryMessageRecord {
21502                            role: gate4agent_types::HistoryMessageRole::Assistant,
21503                            text: "private answer".to_owned(),
21504                        },
21505                    ],
21506                }),
21507                last_error: None,
21508            },
21509            resume: gate4agent_types::ResumeSnapshot::default(),
21510            foreground: gate4agent_types::ForegroundSnapshot::default(),
21511            provider: gate4agent_types::ProviderSnapshot::default(),
21512            screen_state: gate4agent_types::PtyScreenState::default(),
21513        };
21514        let original = NodeSnapshot {
21515            node_id: NodeId::new("node-terminal-test").unwrap(),
21516            enabled_providers: vec![agent("kimi"), agent("codex")],
21517            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21518            workspaces: vec![WorkspaceSnapshot {
21519                workspace_id: WorkspaceId::new("primary").unwrap(),
21520                canonical_root: opaque_windows_path(
21521                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21522                ),
21523                sessions: vec![session],
21524                worktree_service_mode: None,
21525                managed_worktree_profiles: None,
21526            }],
21527            session_records: vec![bound_record],
21528            managed_worktrees: Vec::new(),
21529            launch_inventory: None,
21530            agent_progress: Vec::new(),
21531        };
21532
21533        let mut negotiated = original.clone();
21534        project_snapshot_history_for_wire(&mut negotiated, true);
21535        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21536        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21537        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21538        assert!(!serde_json::to_string(&negotiated)
21539            .unwrap()
21540            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21541
21542        let mut legacy = original;
21543        project_snapshot_history_for_wire(&mut legacy, false);
21544        clear_snapshot_context_packs(&mut legacy);
21545        assert_eq!(
21546            legacy.workspaces[0].sessions[0].history,
21547            gate4agent_types::HistorySnapshot::default(),
21548        );
21549        assert!(legacy.session_records[0].context_id.is_none());
21550        assert!(legacy.session_records[0].context.is_none());
21551        let encoded = serde_json::to_string(&legacy).unwrap();
21552        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21553        assert!(!encoded.contains("context-review"));
21554
21555        let mut reply = ResponseEnvelope {
21556            request_id: 7,
21557            result: Ok(NodeResponse::ContextPackExported { context }),
21558        };
21559        project_response_without_context_pack(&mut reply);
21560        assert_eq!(
21561            reply.result.unwrap_err().code,
21562            NodeFailureCode::UnsupportedCapability,
21563        );
21564    }
21565
21566    #[test]
21567    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21568        let shared = terminal_test_shared();
21569        let history = HistorySessionRecord {
21570            session_id: "fixture-history".to_owned(),
21571            title: None,
21572            cwd: None,
21573            model: None,
21574            message_count: 1,
21575            completed_turn_count: None,
21576            total_tokens: 0,
21577            messages: vec![gate4agent_types::HistoryMessageRecord {
21578                role: gate4agent_types::HistoryMessageRole::User,
21579                text: "bounded handoff".to_owned(),
21580            }],
21581        };
21582        let pack = NodeContextPack::export(
21583            ContextPackLineageReceipt {
21584                source_node_id: shared.node_id.clone(),
21585                source_session: terminal_address(1),
21586                source_provider: agent("claude"),
21587            },
21588            &history,
21589        )
21590        .unwrap();
21591        let context = shared
21592            .context_catalog
21593            .write()
21594            .unwrap_or_else(|poisoned| poisoned.into_inner())
21595            .insert(pack)
21596            .unwrap();
21597        let mut retained = record("codex", "context-retained-record");
21598        retained.context_id = Some(context.id.clone());
21599        retained.context = Some(context.clone());
21600        let record_id = retained.record_id.clone();
21601        shared.insert_record(retained).unwrap();
21602        assert_eq!(
21603            shared.forget_context_pack(&context.id).unwrap_err().code,
21604            NodeFailureCode::ContextPackBusy,
21605        );
21606        assert!(shared
21607            .context_catalog
21608            .read()
21609            .unwrap_or_else(|poisoned| poisoned.into_inner())
21610            .get(&context.id)
21611            .is_some());
21612        shared
21613            .session_records
21614            .lock()
21615            .unwrap_or_else(|poisoned| poisoned.into_inner())
21616            .records
21617            .remove(&record_id);
21618
21619        let mut spec = spawn_spec_fixture();
21620        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21621            value: context.id.clone(),
21622        };
21623        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21624        let receipt = resolved.receipt_with_materialization(
21625            shared.incarnation_id,
21626            terminal_address(2),
21627            None,
21628            None,
21629            Some(context.clone()),
21630        );
21631        assert!(receipt.context_binding_is_valid());
21632        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21633
21634        shared.forget_context_pack(&context.id).unwrap();
21635        assert!(shared
21636            .context_catalog
21637            .read()
21638            .unwrap_or_else(|poisoned| poisoned.into_inner())
21639            .get(&context.id)
21640            .is_none());
21641        assert!(shared
21642            .replay_spawn_spec(&spec, Instant::now())
21643            .unwrap()
21644            .is_none());
21645    }
21646
21647    #[test]
21648    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21649        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21650        let address = terminal_address(1);
21651        let selection = shared
21652            .select_environment_profile(
21653                address.session.instance_id,
21654                &agent("claude"),
21655                SessionMode::Pty,
21656                Some(&environment_profile),
21657            )
21658            .unwrap()
21659            .unwrap();
21660        shared.bind_session_with_policy(
21661            &address,
21662            ProviderRuntimePolicy::raw_pty(),
21663            Some(environment_profile.clone()),
21664        );
21665        selection.retain();
21666        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21667        assert!(matches!(
21668            runtime.remove_native_launch_profile(&native_id),
21669            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21670        ));
21671        assert!(shared.remove_binding(&address).is_some());
21672        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21673
21674        let mismatched = ResolvedEnvironmentProfileReceipt {
21675            profile_id: environment_profile.profile_id,
21676            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21677                "local-claude-r2",
21678            )
21679            .unwrap(),
21680            network_allowlist: None,
21681            browser_profile_id: None,
21682        };
21683        let error = match shared.select_environment_profile(
21684                AgentInstanceId(42),
21685                &agent("claude"),
21686                SessionMode::Pty,
21687                Some(&mismatched),
21688            ) {
21689            Ok(_) => panic!("mismatched environment profile revision was selected"),
21690            Err(error) => error,
21691        };
21692        assert_eq!(
21693            error.code,
21694            NodeFailureCode::EnvironmentProfileBindingMismatch,
21695        );
21696        assert!(!shared
21697            .session_bindings
21698            .lock()
21699            .unwrap_or_else(|poisoned| poisoned.into_inner())
21700            .contains_key(&AgentInstanceId(42)));
21701    }
21702
21703    #[test]
21704    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21705        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21706        let address = terminal_address(1);
21707        let selection = shared
21708            .select_environment_profile(
21709                address.session.instance_id,
21710                &agent("claude"),
21711                SessionMode::Pty,
21712                Some(&environment_profile),
21713            )
21714            .unwrap()
21715            .unwrap();
21716        let overlay = NativeInstanceLaunchOverlay::new(
21717            agent("claude"),
21718            TransportKind::Pty,
21719            vec![EnvMutation {
21720                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21721                value: Some(OsString::from("fixture-secret")),
21722            }],
21723            vec![
21724                OsString::from("--plugin-dir"),
21725                std::env::current_dir().unwrap().into_os_string(),
21726            ],
21727        )
21728        .unwrap();
21729        let instance_overlay = shared
21730            .install_prepared_launch_overlay(
21731                address.session.instance_id,
21732                PreparedNativeLaunchOverlay::Instance(overlay),
21733            )
21734            .unwrap()
21735            .unwrap();
21736        let bundle = ResolvedBundleReceipt {
21737            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21738            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21739            digest: crate::protocol::SpawnBundleDigest::new(format!(
21740                "sha256:{}",
21741                "0".repeat(64),
21742            ))
21743            .unwrap(),
21744        };
21745        shared.bind_session_with_materialization(
21746            &address,
21747            ProviderRuntimePolicy::raw_pty(),
21748            Some(environment_profile),
21749            Some(bundle),
21750            None,
21751            None,
21752        );
21753        selection.retain();
21754        instance_overlay.retain();
21755
21756        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21757        assert!(matches!(
21758            runtime.remove_native_launch_profile(&native_id),
21759            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21760        ));
21761        assert!(shared.remove_binding(&address).is_some());
21762        assert!(!shared
21763            .native_launch_profile_control
21764            .as_ref()
21765            .unwrap()
21766            .clear_native_instance_launch_overlay(address.session.instance_id));
21767        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21768    }
21769
21770    #[test]
21771    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21772        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21773        let address = SessionAddress {
21774            workspace_id: WorkspaceId::new("primary").unwrap(),
21775            session: SessionKey {
21776                instance_id: AgentInstanceId(201),
21777                generation: SessionGeneration(1),
21778            },
21779        };
21780        let error = match shared.prepare_session_materialization(
21781            &address,
21782            &agent("claude"),
21783            SessionMode::Pty,
21784            Some(&receipt),
21785            None,
21786            None,
21787            None,
21788        ) {
21789            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21790            Err(error) => error,
21791        };
21792        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21793        assert!(shared.handle.snapshot().sessions.is_empty());
21794        assert!(shared
21795            .session_bindings
21796            .lock()
21797            .unwrap_or_else(|poisoned| poisoned.into_inner())
21798            .is_empty());
21799        assert!(shared
21800            .materializations
21801            .lock()
21802            .unwrap_or_else(|poisoned| poisoned.into_inner())
21803            .is_empty());
21804        drop(shared);
21805        drop(runtime);
21806        std::fs::remove_dir_all(root).unwrap();
21807    }
21808
21809    #[test]
21810    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21811        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21812        let address = SessionAddress {
21813            workspace_id: WorkspaceId::new("primary").unwrap(),
21814            session: SessionKey {
21815                instance_id: AgentInstanceId(202),
21816                generation: SessionGeneration(1),
21817            },
21818        };
21819        let (overlay, materialization_guard) = shared
21820            .prepare_session_materialization(
21821                &address,
21822                &agent("claude"),
21823                SessionMode::Pty,
21824                Some(&receipt),
21825                None,
21826                None,
21827                None,
21828            )
21829            .unwrap()
21830            .unwrap();
21831        let materialization_id = materialization_guard.id().unwrap().clone();
21832        let materialization_root = shared
21833            .materializations
21834            .lock()
21835            .unwrap_or_else(|poisoned| poisoned.into_inner())
21836            .get(&materialization_id)
21837            .unwrap()
21838            .root()
21839            .to_path_buf();
21840        let selection = shared
21841            .select_environment_profile(
21842                address.session.instance_id,
21843                &agent("claude"),
21844                SessionMode::Pty,
21845                Some(&receipt),
21846            )
21847            .unwrap()
21848            .unwrap();
21849        assert!(shared
21850            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21851            .unwrap()
21852            .is_none());
21853        shared.bind_session_with_materialization(
21854            &address,
21855            ProviderRuntimePolicy::raw_pty(),
21856            Some(receipt),
21857            None,
21858            None,
21859            Some(materialization_id),
21860        );
21861        selection.retain();
21862        materialization_guard.retain();
21863
21864        assert!(materialization_root.is_dir());
21865        assert!(shared
21866            .session_bindings
21867            .lock()
21868            .unwrap_or_else(|poisoned| poisoned.into_inner())
21869            .contains_key(&address.session.instance_id));
21870
21871        let removed = shared.remove_binding(&address).unwrap();
21872        shared
21873            .cleanup_session_owned_materialization(&address, &removed)
21874            .unwrap();
21875        assert!(!materialization_root.exists());
21876        assert!(shared
21877            .materializations
21878            .lock()
21879            .unwrap_or_else(|poisoned| poisoned.into_inner())
21880            .is_empty());
21881        drop(shared);
21882        drop(runtime);
21883        std::fs::remove_dir_all(root).unwrap();
21884    }
21885
21886    #[tokio::test]
21887    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21888        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21889        let address = SessionAddress {
21890            workspace_id: WorkspaceId::new("primary").unwrap(),
21891            session: SessionKey {
21892                instance_id: AgentInstanceId(203),
21893                generation: SessionGeneration(1),
21894            },
21895        };
21896        let (overlay, materialization_guard) = shared
21897            .prepare_session_materialization(
21898                &address,
21899                &agent("claude"),
21900                SessionMode::Pty,
21901                Some(&receipt),
21902                None,
21903                None,
21904                None,
21905            )
21906            .unwrap()
21907            .unwrap();
21908        let materialization_id = materialization_guard.id().unwrap().clone();
21909        let materialization_root = shared
21910            .materializations
21911            .lock()
21912            .unwrap_or_else(|poisoned| poisoned.into_inner())
21913            .get(&materialization_id)
21914            .unwrap()
21915            .root()
21916            .to_path_buf();
21917        let selection = shared
21918            .select_environment_profile(
21919                address.session.instance_id,
21920                &agent("claude"),
21921                SessionMode::Pty,
21922                Some(&receipt),
21923            )
21924            .unwrap()
21925            .unwrap();
21926        assert!(shared
21927            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21928            .unwrap()
21929            .is_none());
21930        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21931        let record_id = shared
21932            .bind_spawn_session_with_materialization(
21933                &address,
21934                agent("claude"),
21935                SessionMode::Pty,
21936                policy,
21937                SpawnRecordPolicy::ProviderIdentityOnly,
21938                Some(receipt.clone()),
21939                None,
21940                None,
21941                Some(materialization_id.clone()),
21942            )
21943            .unwrap()
21944            .unwrap();
21945        selection.retain();
21946        materialization_guard.retain();
21947        assert_eq!(
21948            shared
21949                .materializations
21950                .lock()
21951                .unwrap_or_else(|poisoned| poisoned.into_inner())
21952                .get(&materialization_id)
21953                .unwrap()
21954                .owner(),
21955            &MaterializationOwner::Record {
21956                record_id: record_id.clone(),
21957            },
21958        );
21959
21960        let removed = shared.remove_binding(&address).unwrap();
21961        shared
21962            .cleanup_session_owned_materialization(&address, &removed)
21963            .unwrap();
21964        assert!(materialization_root.is_dir());
21965        assert!(shared
21966            .resolve_record_materialization(
21967                &record_id,
21968                &agent("claude"),
21969                SessionMode::Pty,
21970                Some(&receipt),
21971                None,
21972                None,
21973            )
21974            .unwrap()
21975            .is_some());
21976        {
21977            let mut records = shared
21978                .session_records
21979                .lock()
21980                .unwrap_or_else(|poisoned| poisoned.into_inner());
21981            let record = records.records.get_mut(&record_id).unwrap();
21982            record.active_session = None;
21983            record.state = ManagedSessionState::Unavailable;
21984        }
21985        shared.forget_session_record(&record_id).await.unwrap();
21986        assert!(!materialization_root.exists());
21987        assert!(shared
21988            .materializations
21989            .lock()
21990            .unwrap_or_else(|poisoned| poisoned.into_inner())
21991            .is_empty());
21992        drop(shared);
21993        drop(runtime);
21994        std::fs::remove_dir_all(root).unwrap();
21995    }
21996
21997    #[test]
21998    fn record_materialization_revalidation_failure_persists_recovery_required() {
21999        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22000        let state_path = root.join("node-state-v6.json");
22001        shared.state_path = Some(state_path.clone());
22002        let (_address, _record_id, materialization_id, materialization_root) =
22003            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22004        std::fs::write(
22005            materialization_root.join(".gate4agent-materialization-owner"),
22006            b"tampered",
22007        )
22008        .unwrap();
22009
22010        let error = match shared.resolve_record_materialization(
22011            &_record_id,
22012            &agent("claude"),
22013            SessionMode::Pty,
22014            Some(&receipt),
22015            None,
22016            None,
22017        ) {
22018            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22019            Err(error) => error,
22020        };
22021        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22022        assert_eq!(
22023            shared
22024                .materializations
22025                .lock()
22026                .unwrap_or_else(|poisoned| poisoned.into_inner())
22027                .get(&materialization_id)
22028                .unwrap()
22029                .state(),
22030            MaterializationState::RecoveryRequired,
22031        );
22032        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22033        assert_eq!(loaded.materializations.len(), 1);
22034        assert_eq!(
22035            loaded.materializations[0].state(),
22036            MaterializationState::RecoveryRequired,
22037        );
22038        drop(shared);
22039        drop(runtime);
22040        std::fs::remove_dir_all(root).unwrap();
22041    }
22042
22043    #[test]
22044    fn transient_record_materialization_resolution_failure_remains_ready() {
22045        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22046        let (_address, record_id, materialization_id, _materialization_root) =
22047            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22048        deny.store(true, Ordering::Release);
22049
22050        let error = match shared.resolve_record_materialization(
22051            &record_id,
22052            &agent("claude"),
22053            SessionMode::Pty,
22054            Some(&receipt),
22055            None,
22056            None,
22057        ) {
22058            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22059            Err(error) => error,
22060        };
22061        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22062        assert_eq!(
22063            shared
22064                .materializations
22065                .lock()
22066                .unwrap_or_else(|poisoned| poisoned.into_inner())
22067                .get(&materialization_id)
22068                .unwrap()
22069                .state(),
22070            MaterializationState::Ready,
22071        );
22072        drop(shared);
22073        drop(runtime);
22074        std::fs::remove_dir_all(root).unwrap();
22075    }
22076
22077    #[test]
22078    fn startup_missing_record_materialization_is_not_resumable() {
22079        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22080        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22081        shared
22082            .insert_record(ManagedSessionRecord {
22083                record_id: record_id.clone(),
22084                display_name: "missing materialization".to_owned(),
22085                provider: agent("claude"),
22086                mode: SessionMode::Pty,
22087                state: ManagedSessionState::Dormant,
22088                workspace_id: WorkspaceId::new("primary").unwrap(),
22089                canonical_root: opaque_windows_path(
22090                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22091                ),
22092                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22093                    key: gate4agent_types::ProviderSessionKey::SessionId,
22094                    id: "provider-session-missing-materialization".to_owned(),
22095                    transcript_path: None,
22096                }),
22097                active_session: None,
22098                environment_profile: Some(receipt),
22099                bundle: None,
22100                context_id: None,
22101                context: None,
22102                exported_context: None,
22103                task_binding: None,
22104                created_at_unix_ms: 1,
22105                updated_at_unix_ms: 1,
22106                last_error: None,
22107            })
22108            .unwrap();
22109
22110        shared.reconcile_materializations();
22111        let record = shared.record(&record_id).unwrap();
22112        assert_eq!(record.state, ManagedSessionState::Unavailable);
22113        assert!(record.provider_session.is_none());
22114        assert_eq!(
22115            record.last_error.as_deref(),
22116            Some("environment-profile-unavailable"),
22117        );
22118        drop(shared);
22119        drop(runtime);
22120        std::fs::remove_dir_all(root).unwrap();
22121    }
22122
22123    #[tokio::test]
22124    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22125        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22126        let lease = install_managed_lease(&shared);
22127        let address = SessionAddress {
22128            workspace_id: WorkspaceId::new("primary").unwrap(),
22129            session: SessionKey {
22130                instance_id: AgentInstanceId(204),
22131                generation: SessionGeneration(1),
22132            },
22133        };
22134        let (overlay, materialization_guard) = shared
22135            .prepare_session_materialization(
22136                &address,
22137                &agent("claude"),
22138                SessionMode::Pty,
22139                Some(&receipt),
22140                None,
22141                None,
22142                Some(lease.lease_id.clone()),
22143            )
22144            .unwrap()
22145            .unwrap();
22146        let materialization_id = materialization_guard.id().unwrap().clone();
22147        let materialization_root = shared
22148            .materializations
22149            .lock()
22150            .unwrap_or_else(|poisoned| poisoned.into_inner())
22151            .get(&materialization_id)
22152            .unwrap()
22153            .root()
22154            .to_path_buf();
22155        let selection = shared
22156            .select_environment_profile(
22157                address.session.instance_id,
22158                &agent("claude"),
22159                SessionMode::Pty,
22160                Some(&receipt),
22161            )
22162            .unwrap()
22163            .unwrap();
22164        assert!(shared
22165            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22166            .unwrap()
22167            .is_none());
22168        shared.bind_session_with_materialization(
22169            &address,
22170            ProviderRuntimePolicy::raw_pty(),
22171            Some(receipt),
22172            None,
22173            None,
22174            Some(materialization_id),
22175        );
22176        selection.retain();
22177        materialization_guard.retain();
22178
22179        let blocked = shared
22180            .cleanup_managed_worktree(&lease.lease_id, false)
22181            .await
22182            .unwrap_err();
22183        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22184        assert!(materialization_root.is_dir());
22185        let removed = shared.remove_binding(&address).unwrap();
22186        shared
22187            .cleanup_session_owned_materialization(&address, &removed)
22188            .unwrap();
22189        assert!(!materialization_root.exists());
22190        drop(shared);
22191        drop(runtime);
22192        std::fs::remove_dir_all(root).unwrap();
22193    }
22194
22195    #[tokio::test]
22196    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22197        let git_root = temporary_workspace_root("raw-managed-inventory");
22198        let source = git_root.join("source");
22199        let allocation = git_root.join("allocation");
22200        std::fs::create_dir_all(&source).unwrap();
22201        std::fs::create_dir_all(&allocation).unwrap();
22202        run_test_git(&source, &["init"]);
22203        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22204        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22205        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22206        run_test_git(&source, &["add", "seed.txt"]);
22207        run_test_git(&source, &["commit", "-m", "seed"]);
22208        let profile = ManagedWorktreeProfile::new(
22209            WorktreeProfileId::new("default").unwrap(),
22210            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22211            &allocation,
22212            "gate4agent",
22213            "HEAD",
22214            ManagedWorktreeRetention::RemoveWhenReleased,
22215        )
22216        .unwrap();
22217        let workspace = WorkspaceConfig::new(
22218            WorkspaceId::new("primary").unwrap(),
22219            &source,
22220        )
22221        .unwrap()
22222        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22223        .with_managed_worktree_profile(profile.clone())
22224        .unwrap();
22225        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22226            materialization_test_shared_with_workspace(false, workspace);
22227        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22228        let source_text = source.to_string_lossy().into_owned();
22229        let target_text = target.to_string_lossy().into_owned();
22230        let created = create_git_worktree(
22231            &source_text,
22232            &target_text,
22233            "gate4agent/mw-raw-inventory",
22234            Some("HEAD"),
22235        )
22236        .await
22237        .unwrap();
22238        let lease = ManagedWorktreeLeaseRecord {
22239            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22240            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22241            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22242            profile_id: profile.profile_id().clone(),
22243            profile_revision: profile.revision().clone(),
22244            target_root: created.path.clone(),
22245            branch: "gate4agent/mw-raw-inventory".to_owned(),
22246            base_commit: created.head.clone(),
22247            expected_head: Some(created.head.clone()),
22248            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22249            state: ManagedWorktreeLeaseState::Ready,
22250            session_holders: Vec::new(),
22251            record_holders: Vec::new(),
22252            cleanup_failure: None,
22253            created_at_unix_ms: 1,
22254            updated_at_unix_ms: 1,
22255        };
22256        shared
22257            .workspaces
22258            .write()
22259            .unwrap_or_else(|poisoned| poisoned.into_inner())
22260            .insert(lease.workspace_id.clone(), created.path.clone());
22261        *shared
22262            .managed_worktrees
22263            .lock()
22264            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22265            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22266        let address = SessionAddress {
22267            workspace_id: lease.workspace_id.clone(),
22268            session: SessionKey {
22269                instance_id: AgentInstanceId(205),
22270                generation: SessionGeneration(1),
22271            },
22272        };
22273        let (overlay, materialization_guard) = shared
22274            .prepare_session_materialization(
22275                &address,
22276                &agent("claude"),
22277                SessionMode::Pty,
22278                Some(&receipt),
22279                None,
22280                None,
22281                Some(lease.lease_id.clone()),
22282            )
22283            .unwrap()
22284            .unwrap();
22285        let materialization_id = materialization_guard.id().unwrap().clone();
22286        let materialization_root = shared
22287            .materializations
22288            .lock()
22289            .unwrap_or_else(|poisoned| poisoned.into_inner())
22290            .get(&materialization_id)
22291            .unwrap()
22292            .root()
22293            .to_path_buf();
22294        let selection = shared
22295            .select_environment_profile(
22296                address.session.instance_id,
22297                &agent("claude"),
22298                SessionMode::Pty,
22299                Some(&receipt),
22300            )
22301            .unwrap()
22302            .unwrap();
22303        assert!(shared
22304            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22305            .unwrap()
22306            .is_none());
22307        let record_id = shared
22308            .bind_spawn_session_with_materialization(
22309                &address,
22310                agent("claude"),
22311                SessionMode::Pty,
22312                ProviderRuntimePolicy::raw_pty(),
22313                SpawnRecordPolicy::Always,
22314                Some(receipt.clone()),
22315                None,
22316                None,
22317                Some(materialization_id.clone()),
22318            )
22319            .unwrap()
22320            .unwrap();
22321        selection.retain();
22322        materialization_guard.retain();
22323        let record = shared.record(&record_id).unwrap();
22324        assert_eq!(record.state, ManagedSessionState::Live);
22325        assert!(record.provider_session.is_none());
22326        assert!(record.environment_profile.is_none());
22327        assert!(record.bundle.is_none());
22328        assert!(record.context_id.is_none());
22329        assert!(record.context.is_none());
22330        assert_eq!(record.active_session.as_ref(), Some(&address));
22331        assert_eq!(
22332            shared
22333                .materializations
22334                .lock()
22335                .unwrap_or_else(|poisoned| poisoned.into_inner())
22336                .get(&materialization_id)
22337                .unwrap()
22338                .owner(),
22339            &MaterializationOwner::Session {
22340                incarnation_id: shared.incarnation_id,
22341                instance_id: address.session.instance_id,
22342                generation: address.session.generation,
22343            },
22344        );
22345        {
22346            let mut bindings = shared
22347                .session_bindings
22348                .lock()
22349                .unwrap_or_else(|poisoned| poisoned.into_inner());
22350            bindings
22351                .get_mut(&address.session.instance_id)
22352                .unwrap()
22353                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22354        }
22355        let lease_snapshot = shared
22356            .managed_worktrees
22357            .lock()
22358            .unwrap_or_else(|poisoned| poisoned.into_inner())
22359            .bind_session(
22360                &lease.lease_id,
22361                ManagedWorktreeSessionHolder {
22362                    incarnation_id: shared.incarnation_id,
22363                    instance_id: address.session.instance_id,
22364                    generation: address.session.generation,
22365                },
22366                None,
22367                unix_time_ms(),
22368            )
22369            .unwrap();
22370        assert_eq!(lease_snapshot.active_session_count, 1);
22371        assert_eq!(lease_snapshot.managed_record_count, 0);
22372        shared.persist_state().unwrap();
22373        assert!(shared
22374            .managed_worktrees
22375            .lock()
22376            .unwrap_or_else(|poisoned| poisoned.into_inner())
22377            .get(&lease.lease_id)
22378            .unwrap()
22379            .record_holders
22380            .is_empty());
22381        shared
22382            .handle
22383            .dispatch(shared.prepare_command(ControlCommand::Register {
22384                instance_id: address.session.instance_id,
22385                agent_id: agent("claude"),
22386                transport: TransportKind::Pty,
22387            }))
22388            .unwrap();
22389        runtime.tick().await;
22390        shared.reconcile_managed_record(
22391            &record_id,
22392            &address,
22393            &ControlEvent {
22394                sequence: 1,
22395                command_id: None,
22396                instance_id: address.session.instance_id,
22397                generation: address.session.generation,
22398                event: ControlEventKind::Removed,
22399            },
22400            false,
22401            false,
22402        );
22403        let remove = shared.remove_session(&address);
22404        let drive_runtime = async {
22405            runtime.tick().await;
22406        };
22407        let (removed, ()) = tokio::join!(remove, drive_runtime);
22408        removed.unwrap();
22409
22410        assert!(!materialization_root.exists());
22411        assert!(!Path::new(&created.path).exists());
22412        assert_eq!(
22413            shared
22414                .managed_worktrees
22415                .lock()
22416                .unwrap_or_else(|poisoned| poisoned.into_inner())
22417                .get(&lease.lease_id)
22418                .unwrap()
22419                .state,
22420            ManagedWorktreeLeaseState::Removed,
22421        );
22422        let renamed = shared
22423            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22424            .unwrap();
22425        assert_eq!(renamed.record_id, record_id);
22426        assert_eq!(renamed.display_name, "raw managed inventory");
22427        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22428        assert!(renamed.active_session.is_none());
22429
22430        drop(shared);
22431        drop(runtime);
22432        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22433        std::fs::remove_dir_all(git_root).unwrap();
22434    }
22435
22436    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22437        let root = std::env::current_dir().unwrap()
22438            .join("managed-test-target")
22439            .to_string_lossy()
22440            .into_owned();
22441        let lease = ManagedWorktreeLeaseRecord {
22442            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22443            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22444            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22445            profile_id: WorktreeProfileId::new("default").unwrap(),
22446            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22447            target_root: root.clone(),
22448            branch: "gate4agent/mw-test".to_owned(),
22449            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22450            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22451            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22452            state: ManagedWorktreeLeaseState::Ready,
22453            session_holders: Vec::new(),
22454            record_holders: Vec::new(),
22455            cleanup_failure: None,
22456            created_at_unix_ms: 1,
22457            updated_at_unix_ms: 1,
22458        };
22459        shared.workspaces.write()
22460            .unwrap_or_else(|poisoned| poisoned.into_inner())
22461            .insert(lease.workspace_id.clone(), root);
22462        *shared.managed_worktrees.lock()
22463            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22464            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22465        lease
22466    }
22467
22468    #[cfg(feature = "fixture")]
22469    #[test]
22470    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22471        let mut shared = terminal_test_shared();
22472        shared.fixture_semantic_hook_policy = true;
22473        let lease = install_managed_lease(&shared);
22474        let address = SessionAddress {
22475            workspace_id: lease.workspace_id.clone(),
22476            session: SessionKey {
22477                instance_id: AgentInstanceId(206),
22478                generation: SessionGeneration(1),
22479            },
22480        };
22481        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22482        let effective_runtime_policy =
22483            shared.effective_spawn_runtime_policy(pre_admission_policy);
22484        assert_ne!(effective_runtime_policy, pre_admission_policy);
22485        assert!(effective_runtime_policy.provider_session_identity);
22486
22487        let record_id = shared
22488            .bind_spawn_session(
22489                &address,
22490                agent("claude"),
22491                SessionMode::Pty,
22492                effective_runtime_policy,
22493                SpawnRecordPolicy::Always,
22494                None,
22495            )
22496            .unwrap()
22497            .unwrap();
22498        assert_eq!(
22499            shared.record(&record_id).unwrap().state,
22500            ManagedSessionState::IdentityPending,
22501        );
22502        let (record_holder, raw_inventory_record_id) = {
22503            let mut bindings = shared
22504                .session_bindings
22505                .lock()
22506                .unwrap_or_else(|poisoned| poisoned.into_inner());
22507            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22508            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22509            managed_spawn_record_ownership(binding, effective_runtime_policy)
22510        };
22511        assert_eq!(record_holder.as_ref(), Some(&record_id));
22512        assert!(raw_inventory_record_id.is_none());
22513        let snapshot = shared
22514            .managed_worktrees
22515            .lock()
22516            .unwrap_or_else(|poisoned| poisoned.into_inner())
22517            .bind_session(
22518                &lease.lease_id,
22519                ManagedWorktreeSessionHolder {
22520                    incarnation_id: shared.incarnation_id,
22521                    instance_id: address.session.instance_id,
22522                    generation: address.session.generation,
22523                },
22524                record_holder,
22525                unix_time_ms(),
22526            )
22527            .unwrap();
22528        assert_eq!(snapshot.managed_record_count, 1);
22529        assert_eq!(
22530            shared
22531                .managed_worktrees
22532                .lock()
22533                .unwrap_or_else(|poisoned| poisoned.into_inner())
22534                .get(&lease.lease_id)
22535                .unwrap()
22536                .record_holders,
22537            vec![record_id.clone()],
22538        );
22539
22540        let identity = ProviderSessionIdentity {
22541            key: ProviderSessionKey::SessionId,
22542            id: "monitoring-fixture-managed-session".to_owned(),
22543            transcript_path: None,
22544        };
22545        shared.publish_control(ControlEvent {
22546            sequence: 1,
22547            command_id: None,
22548            instance_id: address.session.instance_id,
22549            generation: address.session.generation,
22550            event: ControlEventKind::ProviderEvent {
22551                sequence: 1,
22552                source: gate4agent_types::ProviderSource {
22553                    family: AdapterFamily::Hook,
22554                    binding: AdapterBinding::new(
22555                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22556                        "fixture/v1",
22557                        gate4agent_types::AdapterVerification::SyntheticFixture,
22558                    )
22559                    .unwrap(),
22560                },
22561                source_sequence: 1,
22562                event: ProviderEvent::SessionIdentityObserved {
22563                    identity: identity.clone(),
22564                },
22565            },
22566        });
22567
22568        let live = shared.record(&record_id).unwrap();
22569        assert_eq!(live.state, ManagedSessionState::Live);
22570        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22571        assert_eq!(live.active_session.as_ref(), Some(&address));
22572        assert_eq!(
22573            shared
22574                .managed_worktrees
22575                .lock()
22576                .unwrap_or_else(|poisoned| poisoned.into_inner())
22577                .get(&lease.lease_id)
22578                .unwrap()
22579                .record_holders,
22580            vec![record_id],
22581        );
22582    }
22583
22584    fn managed_v2_test_shared(
22585        allocation_root: &Path,
22586        state_path: Option<PathBuf>,
22587        profile_revision: &str,
22588    ) -> NodeShared {
22589        let profile = ManagedWorktreeProfile::new(
22590            WorktreeProfileId::new("default").unwrap(),
22591            WorktreeProfileRevision::new(profile_revision).unwrap(),
22592            allocation_root,
22593            "gate4agent",
22594            "HEAD",
22595            ManagedWorktreeRetention::RemoveWhenReleased,
22596        )
22597        .unwrap();
22598        let workspace = WorkspaceConfig::new(
22599            WorkspaceId::new("primary").unwrap(),
22600            std::env::current_dir().unwrap(),
22601        )
22602        .unwrap()
22603        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22604        .with_managed_worktree_profile(profile)
22605        .unwrap();
22606        let catalog = active_registry().unwrap();
22607        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22608        let mut shared = NodeShared::new(
22609            handle,
22610            "fixture-token".to_owned(),
22611            NodeId::new("node-terminal-test").unwrap(),
22612            vec![workspace],
22613            vec![agent("claude")],
22614        );
22615        shared.state_path = state_path;
22616        shared
22617    }
22618
22619    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22620        let mut spawn_spec = spawn_spec_fixture();
22621        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22622        ManagedWorktreeSpawnRequestV2 {
22623            spawn_spec,
22624            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22625            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22626        }
22627    }
22628
22629    #[test]
22630    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22631        let shared = terminal_test_shared();
22632        let mut lease = install_managed_lease(&shared);
22633        let session = SessionAddress {
22634            workspace_id: lease.workspace_id.clone(),
22635            session: SessionKey {
22636                instance_id: AgentInstanceId(73),
22637                generation: SessionGeneration(4),
22638            },
22639        };
22640        lease.state = ManagedWorktreeLeaseState::InUse;
22641        lease.session_holders.push(ManagedWorktreeSessionHolder {
22642            incarnation_id: shared.incarnation_id,
22643            instance_id: session.session.instance_id,
22644            generation: session.session.generation,
22645        });
22646        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22647        assert!(resolved.target.worktree_id.is_none());
22648
22649        let receipt = managed_spawn_receipt(
22650            &resolved,
22651            shared.incarnation_id,
22652            session.clone(),
22653            lease.snapshot(),
22654            None,
22655            None,
22656            None,
22657        );
22658        assert!(resolved.target.worktree_id.is_none());
22659        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22660        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22661        assert_eq!(receipt.spawn.session, session);
22662        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22663        let encoded = serde_json::to_vec(&receipt).unwrap();
22664        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22665        assert_eq!(decoded, receipt);
22666    }
22667
22668    #[tokio::test]
22669    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22670        let root = temporary_workspace_root("managed-v2-revision");
22671        let allocation = root.join("allocation");
22672        std::fs::create_dir_all(&allocation).unwrap();
22673        let shared = managed_v2_test_shared(
22674            &allocation,
22675            Some(root.join("node-state.json")),
22676            "v1",
22677        );
22678        let mut request = managed_v2_request("managed-v2-revision");
22679        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22680
22681        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22682
22683        assert_eq!(
22684            failure.code,
22685            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22686        );
22687        assert!(shared
22688            .managed_worktrees
22689            .lock()
22690            .unwrap_or_else(|poisoned| poisoned.into_inner())
22691            .records()
22692            .is_empty());
22693        assert!(shared
22694            .managed_spawn_replays
22695            .lock()
22696            .unwrap_or_else(|poisoned| poisoned.into_inner())
22697            .is_empty());
22698        std::fs::remove_dir_all(root).unwrap();
22699    }
22700
22701    #[test]
22702    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22703        let root = temporary_workspace_root("managed-v2-concurrent");
22704        let allocation = root.join("allocation");
22705        std::fs::create_dir_all(&allocation).unwrap();
22706        let shared = Arc::new(managed_v2_test_shared(
22707            &allocation,
22708            Some(root.join("node-state.json")),
22709            "v1",
22710        ));
22711        let request = managed_v2_request("managed-v2-concurrent");
22712        let barrier = Arc::new(std::sync::Barrier::new(3));
22713        let mut joins = Vec::new();
22714        for _ in 0..2 {
22715            let shared = Arc::clone(&shared);
22716            let request = request.clone();
22717            let barrier = Arc::clone(&barrier);
22718            joins.push(std::thread::spawn(move || {
22719                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22720                barrier.wait();
22721                shared.acquire_durable_managed_spawn_v2(&request, digest)
22722            }));
22723        }
22724        barrier.wait();
22725        let results = joins
22726            .into_iter()
22727            .map(|join| join.join().unwrap())
22728            .collect::<Vec<_>>();
22729        assert_eq!(
22730            results
22731                .iter()
22732                .filter(|result| {
22733                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22734                })
22735                .count(),
22736            1,
22737        );
22738        assert_eq!(
22739            results
22740                .iter()
22741                .filter(|result| {
22742                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22743                })
22744                .count(),
22745            1,
22746        );
22747        assert_eq!(
22748            shared
22749                .managed_spawn_replays
22750                .lock()
22751                .unwrap_or_else(|poisoned| poisoned.into_inner())
22752                .len(),
22753            1,
22754        );
22755        assert!(shared
22756            .managed_worktrees
22757            .lock()
22758            .unwrap_or_else(|poisoned| poisoned.into_inner())
22759            .records()
22760            .is_empty());
22761        drop(shared);
22762        std::fs::remove_dir_all(root).unwrap();
22763    }
22764
22765    #[tokio::test]
22766    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22767        let root = temporary_workspace_root("managed-v2-linked-pending");
22768        let allocation = root.join("allocation");
22769        std::fs::create_dir_all(&allocation).unwrap();
22770        let state_path = root.join("node-state.json");
22771        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22772        let lease = install_managed_lease(&shared);
22773        let request = managed_v2_request("managed-v2-linked-pending");
22774        assert!(matches!(
22775            shared
22776                .acquire_durable_managed_spawn_v2(
22777                    &request,
22778                    managed_spawn_request_digest_v2(&request).unwrap(),
22779                )
22780                .unwrap(),
22781            DurableManagedSpawnReplayDecision::Reserved,
22782        ));
22783        {
22784            let _transaction = shared
22785                .state_transaction
22786                .lock()
22787                .unwrap_or_else(|poisoned| poisoned.into_inner());
22788            shared
22789                .link_durable_managed_spawn_v2_lease_locked(
22790                    &request.spawn_spec.idempotency_key,
22791                    &lease.lease_id,
22792                )
22793                .unwrap();
22794            shared.persist_state_locked().unwrap();
22795        }
22796        drop(shared);
22797
22798        let mut loaded = session_registry::load(
22799            Some(&state_path),
22800            &NodeId::new("node-terminal-test").unwrap(),
22801        )
22802        .unwrap();
22803        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22804        *reopened
22805            .managed_worktrees
22806            .lock()
22807            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22808            ManagedWorktreeRegistry::from_records(
22809                std::mem::take(&mut loaded.managed_worktrees),
22810                std::mem::take(&mut loaded.managed_worktree_tombstones),
22811            )
22812            .unwrap();
22813        *reopened
22814            .managed_spawn_replays
22815            .lock()
22816            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22817            .managed_spawn_replays
22818            .into_iter()
22819            .map(|record| (record.idempotency_key.clone(), record))
22820            .collect();
22821        let lease_count = reopened
22822            .managed_worktrees
22823            .lock()
22824            .unwrap_or_else(|poisoned| poisoned.into_inner())
22825            .records()
22826            .len();
22827
22828        let failure = reopened
22829            .spawn_managed_worktree_v2(request)
22830            .await
22831            .unwrap_err();
22832
22833        assert_eq!(
22834            failure.code,
22835            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22836        );
22837        assert_eq!(
22838            reopened
22839                .managed_worktrees
22840                .lock()
22841                .unwrap_or_else(|poisoned| poisoned.into_inner())
22842                .records()
22843                .len(),
22844            lease_count,
22845        );
22846        std::fs::remove_dir_all(root).unwrap();
22847    }
22848
22849    #[tokio::test]
22850    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22851        let root = temporary_workspace_root("managed-v2-reopen");
22852        let allocation = root.join("allocation");
22853        std::fs::create_dir_all(&allocation).unwrap();
22854        let state_path = root.join("node-state.json");
22855        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22856        let lease = install_managed_lease(&shared);
22857        let request = managed_v2_request("managed-v2-reopen");
22858        let session = SessionAddress {
22859            workspace_id: lease.workspace_id.clone(),
22860            session: SessionKey {
22861                instance_id: AgentInstanceId(91),
22862                generation: SessionGeneration(1),
22863            },
22864        };
22865        let snapshot = {
22866            let mut registry = shared
22867                .managed_worktrees
22868                .lock()
22869                .unwrap_or_else(|poisoned| poisoned.into_inner());
22870            let current = registry.get_mut(&lease.lease_id).unwrap();
22871            current.state = ManagedWorktreeLeaseState::InUse;
22872            current.session_holders.push(ManagedWorktreeSessionHolder {
22873                incarnation_id: shared.incarnation_id,
22874                instance_id: session.session.instance_id,
22875                generation: session.session.generation,
22876            });
22877            current.snapshot()
22878        };
22879        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22880        let receipt = managed_spawn_receipt(
22881            &resolved,
22882            shared.incarnation_id,
22883            session,
22884            snapshot,
22885            None,
22886            None,
22887            None,
22888        );
22889        assert!(matches!(
22890            shared
22891                .acquire_durable_managed_spawn_v2(
22892                    &request,
22893                    managed_spawn_request_digest_v2(&request).unwrap(),
22894                )
22895                .unwrap(),
22896            DurableManagedSpawnReplayDecision::Reserved,
22897        ));
22898        {
22899            let _transaction = shared
22900                .state_transaction
22901                .lock()
22902                .unwrap_or_else(|poisoned| poisoned.into_inner());
22903            shared
22904                .link_durable_managed_spawn_v2_lease_locked(
22905                    &request.spawn_spec.idempotency_key,
22906                    &lease.lease_id,
22907                )
22908                .unwrap();
22909            shared.persist_state_locked().unwrap();
22910        }
22911        shared
22912            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22913            .unwrap();
22914        let legacy_same_key = ManagedWorktreeSpawnRequest {
22915            spawn_spec: request.spawn_spec.clone(),
22916            worktree_profile_id: request.worktree_profile_id.clone(),
22917        };
22918        shared.remember_managed_spawn_attempt(
22919            Some(&request.spawn_spec.idempotency_key),
22920            legacy_same_key.clone(),
22921            Ok(receipt.clone()),
22922            Instant::now(),
22923        );
22924        assert!(shared
22925            .replay_managed_spawn(&legacy_same_key, Instant::now())
22926            .unwrap()
22927            .is_none());
22928        drop(shared);
22929
22930        let mut loaded = session_registry::load(
22931            Some(&state_path),
22932            &NodeId::new("node-terminal-test").unwrap(),
22933        )
22934        .unwrap();
22935        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22936        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22937        *reopened
22938            .managed_worktrees
22939            .lock()
22940            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22941            ManagedWorktreeRegistry::from_records(
22942                std::mem::take(&mut loaded.managed_worktrees),
22943                std::mem::take(&mut loaded.managed_worktree_tombstones),
22944            )
22945            .unwrap();
22946        *reopened
22947            .managed_spawn_replays
22948            .lock()
22949            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22950            .managed_spawn_replays
22951            .into_iter()
22952            .map(|record| (record.idempotency_key.clone(), record))
22953            .collect();
22954        let lease_count_before = reopened
22955            .managed_worktrees
22956            .lock()
22957            .unwrap_or_else(|poisoned| poisoned.into_inner())
22958            .records()
22959            .len();
22960
22961        assert_eq!(
22962            reopened
22963                .spawn_managed_worktree_v2(request.clone())
22964                .await
22965                .unwrap(),
22966            receipt,
22967        );
22968        assert_eq!(
22969            reopened
22970                .managed_worktrees
22971                .lock()
22972                .unwrap_or_else(|poisoned| poisoned.into_inner())
22973                .records()
22974                .len(),
22975            lease_count_before,
22976        );
22977        let mut changed = request;
22978        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22979        let failure = reopened
22980            .spawn_managed_worktree_v2(changed)
22981            .await
22982            .unwrap_err();
22983        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22984        assert_eq!(
22985            reopened
22986                .managed_worktrees
22987                .lock()
22988                .unwrap_or_else(|poisoned| poisoned.into_inner())
22989                .records()
22990                .len(),
22991            lease_count_before,
22992        );
22993        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22994        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22995        let failure = reopened
22996            .spawn_managed_worktree_v2(new_key)
22997            .await
22998            .unwrap_err();
22999        assert_eq!(
23000            failure.code,
23001            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23002        );
23003        assert_eq!(
23004            reopened
23005                .managed_worktrees
23006                .lock()
23007                .unwrap_or_else(|poisoned| poisoned.into_inner())
23008                .records()
23009                .len(),
23010            lease_count_before,
23011        );
23012        assert_eq!(
23013            reopened
23014                .managed_spawn_replays
23015                .lock()
23016                .unwrap_or_else(|poisoned| poisoned.into_inner())
23017                .len(),
23018            1,
23019        );
23020        std::fs::remove_dir_all(root).unwrap();
23021    }
23022
23023    #[test]
23024    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23025        let shared = terminal_test_shared();
23026        let request = ManagedWorktreeSpawnRequest {
23027            spawn_spec: spawn_spec_fixture(),
23028            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23029        };
23030        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23031        shared.remember_managed_spawn(
23032            request.clone(),
23033            Err(expected.clone()),
23034            Instant::now(),
23035        );
23036
23037        assert_eq!(
23038            shared
23039                .replay_managed_spawn(&request, Instant::now())
23040                .unwrap()
23041                .unwrap(),
23042            Err(expected),
23043        );
23044        assert!(shared
23045            .managed_spawn_replays
23046            .lock()
23047            .unwrap_or_else(|poisoned| poisoned.into_inner())
23048            .is_empty());
23049    }
23050
23051    #[tokio::test]
23052    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23053        let shared = terminal_test_shared();
23054        let lease = install_managed_lease(&shared);
23055        let raw = shared.spawn_session(
23056            lease.workspace_id.clone(),
23057            agent("claude"),
23058            SessionMode::Pty,
23059            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23060            None,
23061        ).await.unwrap_err();
23062        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23063        let mut spec = spawn_spec_fixture();
23064        spec.target.workspace_id = lease.workspace_id.clone();
23065        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23066        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23067        let mut selected = spawn_spec_fixture();
23068        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23069        selected.target.worktree_id = Some(lease.workspace_id);
23070        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23071        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23072        assert!(shared.handle.snapshot().sessions.is_empty());
23073    }
23074
23075    #[test]
23076    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23077        let shared = terminal_test_shared();
23078        let lease = install_managed_lease(&shared);
23079        let snapshot = snapshot_for_wire(
23080            &shared, true, true, true, false, true, true, false, false, true,
23081        );
23082        assert!(snapshot.managed_worktrees.is_empty());
23083        assert!(snapshot.workspaces.iter().all(|workspace| {
23084            workspace.managed_worktree_profiles.is_none()
23085        }));
23086        let mut reply = ResponseEnvelope {
23087            request_id: 1,
23088            result: Ok(NodeResponse::Resync {
23089                event_sequence: 1,
23090                oldest_available_sequence: 1,
23091                snapshot: shared.snapshot(),
23092                events: vec![NodeEventEnvelope {
23093                    sequence: 1,
23094                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23095                }],
23096            }),
23097        };
23098        project_response_without_managed_worktrees(&mut reply);
23099        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23100            panic!("projection changed the response kind");
23101        };
23102        assert!(snapshot.managed_worktrees.is_empty());
23103        assert!(events.is_empty());
23104        let mut git = git_snapshot_for_parser();
23105        git.branch = Some(lease.branch.clone());
23106        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23107            lease_id: lease.lease_id.clone(),
23108            source_workspace_id: lease.source_workspace_id.clone(),
23109            branch: lease.branch.clone(),
23110            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23111            active_session_count: 1,
23112            managed_record_count: 0,
23113        });
23114        let mut inspection_reply = ResponseEnvelope {
23115            request_id: 2,
23116            result: Ok(NodeResponse::WorkspaceInspected {
23117                inspection: WorkspaceInspection {
23118                    workspace_id: lease.workspace_id.clone(),
23119                    entries: Vec::new(),
23120                    tree_truncated: false,
23121                    git,
23122                    truncation: None,
23123                },
23124            }),
23125        };
23126        project_response_without_managed_worktrees(&mut inspection_reply);
23127        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23128            panic!("projection changed the inspection response kind");
23129        };
23130        assert!(inspection.git.managed_worktree.is_none());
23131        let failure = managed_git_worktree_failure(GitWorktreeError {
23132            kind: GitWorktreeErrorKind::Failed,
23133            message: r"git failed at C:\private\managed-target".to_owned(),
23134        });
23135        assert!(!failure.message.contains("private"));
23136        assert!(!failure.message.contains("managed-target"));
23137    }
23138
23139    #[tokio::test]
23140    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23141        let root = temporary_workspace_root("managed-restart");
23142        let source = root.join("source");
23143        let allocation = root.join("allocation");
23144        std::fs::create_dir_all(&source).unwrap();
23145        std::fs::create_dir_all(&allocation).unwrap();
23146        run_test_git(&source, &["init"]);
23147        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23148        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23149        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23150        run_test_git(&source, &["add", "seed.txt"]);
23151        run_test_git(&source, &["commit", "-m", "seed"]);
23152        let profile = ManagedWorktreeProfile::new(
23153            WorktreeProfileId::new("default").unwrap(),
23154            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23155            &allocation,
23156            "gate4agent",
23157            "HEAD",
23158            ManagedWorktreeRetention::Retain,
23159        ).unwrap();
23160        let workspace = WorkspaceConfig::new(
23161            WorkspaceId::new("primary").unwrap(),
23162            &source,
23163        ).unwrap()
23164            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23165            .with_managed_worktree_profile(profile.clone()).unwrap();
23166        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23167        let source_text = workspace.canonical_root().to_owned();
23168        let target_text = target.to_string_lossy().into_owned();
23169        let created = create_git_worktree(
23170            &source_text,
23171            &target_text,
23172            "gate4agent/mw-restart",
23173            Some("HEAD"),
23174        ).await.unwrap();
23175        let lease = ManagedWorktreeLeaseRecord {
23176            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23177            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23178            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23179            profile_id: profile.profile_id().clone(),
23180            profile_revision: profile.revision().clone(),
23181            target_root: created.path.clone(),
23182            branch: "gate4agent/mw-restart".to_owned(),
23183            base_commit: created.head.clone(),
23184            expected_head: None,
23185            retention: ManagedWorktreeRetention::Retain,
23186            state: ManagedWorktreeLeaseState::Allocating,
23187            session_holders: Vec::new(),
23188            record_holders: Vec::new(),
23189            cleanup_failure: None,
23190            created_at_unix_ms: 1,
23191            updated_at_unix_ms: 1,
23192        };
23193        let catalog = active_registry().unwrap();
23194        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23195        let shared = NodeShared::new_with_incarnation(
23196            handle,
23197            "fixture-token".to_owned(),
23198            NodeId::new("node-managed-restart").unwrap(),
23199            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23200            vec![workspace],
23201            vec![agent("claude")],
23202            ProviderRuntimeStatuses::default(),
23203            None,
23204            Vec::new(),
23205            Vec::new(),
23206            SpawnProfileRegistry::default(),
23207            None,
23208            None,
23209            None,
23210            None,
23211            Vec::new(),
23212            vec![lease.clone()],
23213            Vec::new(),
23214            Vec::new(),
23215            None,
23216            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23217        );
23218        shared.reconcile_managed_worktrees().await;
23219        let retained = shared.managed_worktrees.lock()
23220            .unwrap_or_else(|poisoned| poisoned.into_inner())
23221            .get(&lease.lease_id).unwrap().clone();
23222        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23223        assert!(shared.workspaces.read()
23224            .unwrap_or_else(|poisoned| poisoned.into_inner())
23225            .contains_key(&lease.workspace_id));
23226        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23227        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23228        assert!(!Path::new(&created.path).exists());
23229        std::fs::remove_dir_all(root).unwrap();
23230    }
23231
23232    fn run_test_git(root: &Path, arguments: &[&str]) {
23233        let output = std::process::Command::new("git")
23234            .arg("-C").arg(root).args(arguments).output().unwrap();
23235        assert!(
23236            output.status.success(),
23237            "git {:?} failed: {}",
23238            arguments,
23239            String::from_utf8_lossy(&output.stderr),
23240        );
23241    }
23242
23243    #[tokio::test]
23244    async fn spawn_spec_validation_fails_before_session_mutation() {
23245        let shared = terminal_test_shared();
23246        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23247            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23248        }));
23249        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23250            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23251        }));
23252        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23253            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23254        }));
23255
23256        let mut target_mismatch = spawn_spec_fixture();
23257        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23258        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23259        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23260
23261        let mut unknown_profile = spawn_spec_fixture();
23262        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23263        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23264        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23265        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23266
23267        let mut revision_mismatch = spawn_spec_fixture();
23268        revision_mismatch.idempotency_key =
23269            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23270        revision_mismatch.expected_profile_revision =
23271            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23272        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23273        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23274
23275        let mut unsupported_materializer = spawn_spec_fixture();
23276        unsupported_materializer.idempotency_key =
23277            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23278        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23279            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23280        };
23281        let failure = shared
23282            .spawn_from_spec(unsupported_materializer)
23283            .await
23284            .unwrap_err();
23285        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23286
23287        let failure = shared
23288            .spawn_session_with_deadline(
23289                WorkspaceId::new("primary").unwrap(),
23290                agent("claude"),
23291                SessionMode::Pty,
23292                gate4agent_types::TerminalSize {
23293                    rows: 24,
23294                    columns: 80,
23295                },
23296                None,
23297                None,
23298                None,
23299                None,
23300                None,
23301                None,
23302                SpawnRecordPolicy::ProviderIdentityOnly,
23303                Some(Instant::now()),
23304                &[],
23305                None,
23306                ApprovalLevel::default(),
23307                None,
23308                None,
23309            )
23310            .await
23311            .unwrap_err();
23312        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23313        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23314        assert!(shared.handle.snapshot().sessions.is_empty());
23315    }
23316
23317    #[test]
23318    fn worktree_selection_requires_its_separate_negotiated_capability() {
23319        let mut spec = spawn_spec_fixture();
23320        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23321        let request = NodeRequest::SpawnSpec { spec };
23322        let spawn_spec_capability =
23323            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23324        assert!(request_uses_unnegotiated_capability(
23325            &request,
23326            &[spawn_spec_capability.clone()],
23327        ));
23328        let worktree_capability =
23329            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23330        assert!(request_uses_unnegotiated_capability(
23331            &request,
23332            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23333        ));
23334        let profile_revision_capability =
23335            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23336        assert!(request_uses_unnegotiated_capability(
23337            &request,
23338            &[
23339                spawn_spec_capability.clone(),
23340                worktree_capability.clone(),
23341                profile_revision_capability.clone(),
23342            ],
23343        ));
23344        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23345        // `Inherit`, not `Clear` — the profile's own defaults might still
23346        // materialize a bundle or a context pack, so both capabilities stay
23347        // conservatively required until negotiated (see
23348        // `requires_session_bundle_materialization_capability` /
23349        // `requires_history_context_pack_capability`).
23350        let bundle_materialization_capability =
23351            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23352        assert!(request_uses_unnegotiated_capability(
23353            &request,
23354            &[
23355                spawn_spec_capability.clone(),
23356                worktree_capability.clone(),
23357                profile_revision_capability.clone(),
23358                bundle_materialization_capability.clone(),
23359            ],
23360        ));
23361        let history_context_pack_capability =
23362            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23363        assert!(!request_uses_unnegotiated_capability(
23364            &request,
23365            &[
23366                spawn_spec_capability,
23367                worktree_capability,
23368                profile_revision_capability,
23369                bundle_materialization_capability,
23370                history_context_pack_capability,
23371            ],
23372        ));
23373    }
23374
23375    #[tokio::test]
23376    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23377        let mut shared = terminal_test_shared();
23378        let spec = spawn_spec_fixture();
23379        let receipt = shared
23380            .resolve_spawn_spec(&spec)
23381            .unwrap()
23382            .receipt(shared.incarnation_id, terminal_address(1));
23383        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23384
23385        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23386        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23387        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23388
23389        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23390
23391        let mut revision_conflicting = spec.clone();
23392        revision_conflicting.expected_profile_revision =
23393            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23394        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23395        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23396
23397        let mut stale_cache_miss = spec.clone();
23398        stale_cache_miss.idempotency_key =
23399            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23400        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23401        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23402
23403        let mut conflicting = spec;
23404        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23405        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23406        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23407        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23408        assert!(shared.handle.snapshot().sessions.is_empty());
23409    }
23410
23411    #[tokio::test]
23412    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23413        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23414        let mut spec = spawn_spec_fixture();
23415        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23416        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23417        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23418
23419        let failure = shared
23420            .create_worktree(
23421                WorkspaceId::new("primary").unwrap(),
23422                WorkspaceId::new("new-worktree").unwrap(),
23423                "not-an-absolute-path".to_owned(),
23424                "invalid branch".to_owned(),
23425                None,
23426            )
23427            .await
23428            .unwrap_err();
23429        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23430
23431        let failure = shared
23432            .remove_worktree(
23433                WorkspaceId::new("primary").unwrap(),
23434                "not-an-absolute-path".to_owned(),
23435            )
23436            .await
23437            .unwrap_err();
23438        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23439        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23440        assert!(shared.handle.snapshot().sessions.is_empty());
23441    }
23442
23443    #[tokio::test]
23444    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23445        let root = temporary_workspace_root("manual-worktree-compensation");
23446        let source = root.join("source");
23447        let target = root.join("target");
23448        let invalid_state_parent = root.join("state-parent-is-a-file");
23449        let invalid_state_path = invalid_state_parent.join("state.json");
23450        std::fs::create_dir_all(&source).unwrap();
23451        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23452        run_test_git(&source, &["init"]);
23453        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23454        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23455        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23456        run_test_git(&source, &["add", "seed.txt"]);
23457        run_test_git(&source, &["commit", "-m", "seed"]);
23458
23459        let catalog = active_registry().unwrap();
23460        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23461        let workspace = WorkspaceConfig::new(
23462            WorkspaceId::new("primary").unwrap(),
23463            &source,
23464        ).unwrap();
23465        let mut shared = NodeShared::new(
23466            handle,
23467            "fixture-token".to_owned(),
23468            NodeId::new("node-worktree-compensation").unwrap(),
23469            vec![workspace],
23470            vec![agent("claude")],
23471        );
23472        shared.state_path = Some(invalid_state_path);
23473
23474        let error = shared.create_worktree(
23475            WorkspaceId::new("primary").unwrap(),
23476            WorkspaceId::new("compensated").unwrap(),
23477            target.to_string_lossy().into_owned(),
23478            "gate4agent/compensated".to_owned(),
23479            Some("HEAD".to_owned()),
23480        ).await.unwrap_err();
23481
23482        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23483        assert!(!target.exists());
23484        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23485            .iter()
23486            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23487        std::fs::remove_dir_all(root).unwrap();
23488    }
23489
23490    #[test]
23491    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23492        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23493            NativeGitWorktreeSnapshot {
23494                path: path.to_owned(),
23495                head: "0123456789abcdef".to_owned(),
23496                branch: Some("refs/heads/test".to_owned()),
23497                is_bare,
23498                is_main,
23499                locked: false,
23500                lock_reason: None,
23501                prunable: false,
23502                prunable_reason: None,
23503                workspace_id: None,
23504            }
23505        }
23506
23507        let source = r"C:\repo";
23508        let selected = r"C:\repo-worktree";
23509        let listed = vec![
23510            worktree(source, true, false),
23511            worktree(selected, false, false),
23512        ];
23513        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23514
23515        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23516        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23517
23518        let bare = vec![
23519            worktree(source, true, false),
23520            worktree(selected, false, true),
23521        ];
23522        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23523        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23524
23525        let foreign_failure =
23526            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23527        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23528    }
23529
23530    fn terminal_frame(sequence: u64) -> TerminalFrame {
23531        TerminalFrame {
23532            sequence,
23533            size: gate4agent_types::TerminalSize {
23534                rows: 24,
23535                columns: 80,
23536            },
23537            cursor_row: 2,
23538            cursor_column: 3,
23539            contents: format!("frame-{sequence}"),
23540            formatted: vec![1, 2, 3],
23541            scrollback_formatted: Vec::new(),
23542            alternate_screen: false,
23543            mouse_protocol_enabled: false,
23544            mouse_protocol_encoding: Default::default(),
23545            produced_at_unix_ms: 0,
23546            screen_state: gate4agent_types::PtyScreenState::default(),
23547            bracketed_paste: None,
23548        }
23549    }
23550
23551    #[test]
23552    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23553        let shared = terminal_test_shared();
23554        let mut receiver = shared.terminal_event_tx.subscribe();
23555        let original = terminal_address(1);
23556
23557        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23558        let first = receiver.try_recv().unwrap();
23559        assert_eq!(first.len(), 1);
23560        assert!(matches!(
23561            &first[0].event,
23562            NodeEvent::TerminalFrame { address, frame }
23563                if address == &original && frame.sequence == 7
23564        ));
23565
23566        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23567        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23568        assert!(matches!(
23569            receiver.try_recv(),
23570            Err(broadcast::error::TryRecvError::Empty)
23571        ));
23572
23573        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23574        let advanced = receiver.try_recv().unwrap();
23575        assert_eq!(advanced[0].sequence, 2);
23576
23577        let resumed = terminal_address(2);
23578        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23579        let reset = receiver.try_recv().unwrap();
23580        assert_eq!(reset[0].sequence, 3);
23581        assert!(matches!(
23582            &reset[0].event,
23583            NodeEvent::TerminalFrame { address, frame }
23584                if address == &resumed && frame.sequence == 1
23585        ));
23586        shared.clear_terminal_frame_watermark(&resumed);
23587        assert!(!shared
23588            .terminal_frame_watermarks
23589            .lock()
23590            .unwrap_or_else(|poisoned| poisoned.into_inner())
23591            .contains_key(&resumed.session.instance_id));
23592    }
23593
23594    #[test]
23595    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23596        let shared = terminal_test_shared();
23597        let mut receiver = shared.terminal_event_tx.subscribe();
23598        let address = terminal_address(1);
23599        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23600
23601        for sequence in 1..=count {
23602            shared.publish_terminal_frame_candidates(vec![(
23603                address.clone(),
23604                terminal_frame(sequence),
23605            )]);
23606        }
23607
23608        let history = shared
23609            .history
23610            .lock()
23611            .unwrap_or_else(|poisoned| poisoned.into_inner());
23612        assert!(history.events.is_empty());
23613        assert_eq!(history.last_sequence, count);
23614        assert_eq!(history.replay_floor_sequence, 1);
23615        drop(history);
23616        let NodeResponse::Resync {
23617            event_sequence,
23618            oldest_available_sequence,
23619            events,
23620            ..
23621        } = shared.resync(0) else {
23622            unreachable!("resync helper returned another response")
23623        };
23624        assert_eq!(event_sequence, count);
23625        assert_eq!(oldest_available_sequence, 1);
23626        assert!(events.is_empty());
23627        assert!(matches!(
23628            resync_required_event(&shared).event,
23629            NodeEvent::ResyncRequired {
23630                oldest_available_sequence: 1,
23631            }
23632        ));
23633        assert!(matches!(
23634            receiver.try_recv(),
23635            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23636        ));
23637        let latest = receiver.try_recv().unwrap();
23638        assert_eq!(latest.len(), 1);
23639        assert_eq!(latest[0].sequence, count);
23640    }
23641
23642    #[test]
23643    fn real_durable_eviction_advances_authoritative_replay_floor() {
23644        let shared = terminal_test_shared();
23645        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23646            shared.publish(NodeEvent::ControllerChanged { controller: None });
23647        }
23648
23649        let NodeResponse::Resync {
23650            event_sequence,
23651            oldest_available_sequence,
23652            events,
23653            ..
23654        } = shared.resync(0) else {
23655            unreachable!("resync helper returned another response")
23656        };
23657        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23658        assert_eq!(oldest_available_sequence, 2);
23659        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23660        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23661        assert!(matches!(
23662            resync_required_event(&shared).event,
23663            NodeEvent::ResyncRequired {
23664                oldest_available_sequence: 2,
23665            }
23666        ));
23667    }
23668
23669    #[test]
23670    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23671        let shared = terminal_test_shared();
23672        let without_capability = terminal_event_subscription(&shared, false);
23673        let with_capability = terminal_event_subscription(&shared, true);
23674        assert!(without_capability.is_none());
23675        assert!(with_capability.is_some());
23676        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23677            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23678        }));
23679    }
23680
23681    #[test]
23682    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23683        let mut pending = Vec::new();
23684        let mut event_sequence = 0_u64;
23685        for frame_sequence in 1..=4 {
23686            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23687                event_sequence += 1;
23688                let mut address = terminal_address(1);
23689                address.session.instance_id = AgentInstanceId(instance);
23690                queue_connection_event(
23691                    &mut pending,
23692                    NodeEventEnvelope {
23693                        sequence: event_sequence,
23694                        event: NodeEvent::TerminalFrame {
23695                            address,
23696                            frame: terminal_frame(frame_sequence),
23697                        },
23698                    },
23699                    0,
23700                );
23701            }
23702        }
23703        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23704        pending.sort_unstable_by_key(|event| event.sequence);
23705
23706        let mut uninterrupted = pending.clone();
23707        let first_burst = connection_event_burst_len(uninterrupted.len());
23708        uninterrupted.drain(..first_burst);
23709        let second_burst = connection_event_burst_len(uninterrupted.len());
23710        uninterrupted.drain(..second_burst);
23711        assert!(uninterrupted.is_empty());
23712
23713        let (frame_tx, mut frame_rx) =
23714            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23715        frame_tx
23716            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23717                request_id: 77,
23718                request: NodeRequest::Snapshot,
23719            })))
23720            .unwrap();
23721        let burst_len = connection_event_burst_len(pending.len());
23722        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23723        pending.drain(..burst_len);
23724        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23725        assert!(matches!(
23726            frame_rx.try_recv(),
23727            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23728                request_id: 77,
23729                request: NodeRequest::Snapshot,
23730            })))
23731        ));
23732    }
23733
23734    #[test]
23735    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23736        let mut pending = Vec::new();
23737        for sequence in [7_u64, 8] {
23738            let mut address = terminal_address(1);
23739            address.session.instance_id = AgentInstanceId(sequence);
23740            queue_connection_event(
23741                &mut pending,
23742                NodeEventEnvelope {
23743                    sequence,
23744                    event: NodeEvent::TerminalFrame {
23745                        address,
23746                        frame: terminal_frame(sequence),
23747                    },
23748                },
23749                0,
23750            );
23751        }
23752        let mut discard_through = 0;
23753        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23754        assert!(pending.is_empty());
23755
23756        let replayed = NodeEventEnvelope {
23757            sequence: 8,
23758            event: NodeEvent::TerminalFrame {
23759                address: terminal_address(1),
23760                frame: terminal_frame(8),
23761            },
23762        };
23763        let advanced = NodeEventEnvelope {
23764            sequence: 9,
23765            event: NodeEvent::TerminalFrame {
23766                address: terminal_address(2),
23767                frame: terminal_frame(1),
23768            },
23769        };
23770        queue_connection_event_batch(
23771            &mut pending,
23772            &[replayed, advanced.clone()],
23773            discard_through,
23774        );
23775        assert_eq!(pending, vec![advanced]);
23776    }
23777
23778    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23779        ManagedSessionRecord {
23780            record_id: SessionRecordId::new(record_id).unwrap(),
23781            display_name: format!("{provider} record"),
23782            provider: agent(provider),
23783            mode: SessionMode::Pty,
23784            state: ManagedSessionState::Unavailable,
23785            workspace_id: WorkspaceId::new("primary").unwrap(),
23786            canonical_root: opaque_windows_path(
23787                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23788            ),
23789            provider_session: None,
23790            active_session: None,
23791            environment_profile: None,
23792            bundle: None,
23793            context_id: None,
23794            context: None,
23795            exported_context: None,
23796            task_binding: None,
23797            created_at_unix_ms: 1,
23798            updated_at_unix_ms: 1,
23799            last_error: None,
23800        }
23801    }
23802
23803    fn retention_test_record(
23804        workspace_id: &str,
23805        record_id: &str,
23806        state: ManagedSessionState,
23807        updated_at_unix_ms: u64,
23808    ) -> ManagedSessionRecord {
23809        ManagedSessionRecord {
23810            record_id: SessionRecordId::new(record_id).unwrap(),
23811            display_name: format!("{record_id} display"),
23812            provider: agent("claude"),
23813            mode: SessionMode::Pty,
23814            state,
23815            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23816            canonical_root: opaque_windows_path(
23817                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23818            ),
23819            provider_session: None,
23820            active_session: None,
23821            environment_profile: None,
23822            bundle: None,
23823            context_id: None,
23824            context: None,
23825            exported_context: None,
23826            task_binding: None,
23827            created_at_unix_ms: updated_at_unix_ms,
23828            updated_at_unix_ms,
23829            last_error: None,
23830        }
23831    }
23832
23833    /// Two workspaces of three `Unavailable` records each, staggered by
23834    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23835    /// `workspace-a` (all as old as that workspace's oldest real record --
23836    /// if the sweep's state gate ever leaked, these would be the first
23837    /// thing it wrongly retired). Ages, oldest to newest:
23838    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23839    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23840    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23841        vec![
23842            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23843            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23844            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23845            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23846            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23847            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23848            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23849            retention_test_record(
23850                "workspace-a",
23851                "decoy-pending",
23852                ManagedSessionState::IdentityPending,
23853                now - 500_000,
23854            ),
23855            retention_test_record(
23856                "workspace-a",
23857                "decoy-dormant",
23858                ManagedSessionState::Dormant,
23859                now - 500_000,
23860            ),
23861        ]
23862    }
23863
23864    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23865        let root = temporary_workspace_root("session-record-retention");
23866        std::fs::create_dir_all(&root).unwrap();
23867        let catalog = active_registry().unwrap();
23868        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23869        let workspace_a =
23870            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23871        let workspace_b =
23872            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23873        let mut shared = NodeShared::new(
23874            handle,
23875            "fixture-token".to_owned(),
23876            NodeId::new("node-retention-test").unwrap(),
23877            vec![workspace_a, workspace_b],
23878            vec![agent("claude")],
23879        );
23880        shared.state_path = Some(root.join("node-state-v10.json"));
23881        let now = unix_time_ms();
23882        for record in retention_fixture_records(now) {
23883            shared.insert_record(record).unwrap();
23884        }
23885        shared
23886            .persist_state_locked()
23887            .expect("fixture-only, empty-history persist against a fresh temp state path");
23888        (shared, runtime, root, now)
23889    }
23890
23891    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23892        shared
23893            .history
23894            .lock()
23895            .unwrap_or_else(|poisoned| poisoned.into_inner())
23896            .events
23897            .iter()
23898            .filter_map(|envelope| match &envelope.event {
23899                NodeEvent::SessionRecordRemoved { record_id } => {
23900                    Some(record_id.as_str().to_owned())
23901                }
23902                _ => None,
23903            })
23904            .collect()
23905    }
23906
23907    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23908        shared
23909            .session_records
23910            .lock()
23911            .unwrap_or_else(|poisoned| poisoned.into_inner())
23912            .records
23913            .keys()
23914            .map(|record_id| record_id.as_str().to_owned())
23915            .collect()
23916    }
23917
23918    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23919        values.iter().map(|value| (*value).to_owned()).collect()
23920    }
23921
23922    #[tokio::test]
23923    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23924        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23925        shared.session_record_retention = SessionRecordRetentionConfig {
23926            age_ms: 0,
23927            keep_per_workspace: 1,
23928        };
23929        shared.retire_unavailable_session_records().await;
23930
23931        assert_eq!(
23932            retention_remaining_record_ids(&shared),
23933            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23934        );
23935        assert_eq!(
23936            retention_removed_record_ids(&shared),
23937            owned(&["a1", "a2", "b1", "b2"]),
23938        );
23939        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23940
23941        drop(shared);
23942        drop(runtime);
23943        std::fs::remove_dir_all(root).unwrap();
23944    }
23945
23946    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23947    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23948    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23949    /// 200s old against a 250s threshold) even though it fails the keep-N
23950    /// test too, proving retirement needs BOTH, not either.
23951    #[tokio::test]
23952    async fn session_record_retention_both_set_retires_only_the_intersection() {
23953        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23954        shared.session_record_retention = SessionRecordRetentionConfig {
23955            age_ms: 250_000,
23956            keep_per_workspace: 1,
23957        };
23958        shared.retire_unavailable_session_records().await;
23959
23960        assert_eq!(
23961            retention_remaining_record_ids(&shared),
23962            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23963        );
23964        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23965        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23966
23967        drop(shared);
23968        drop(runtime);
23969        std::fs::remove_dir_all(root).unwrap();
23970    }
23971
23972    #[tokio::test]
23973    async fn session_record_retention_both_disabled_is_a_no_op() {
23974        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23975        shared.session_record_retention = SessionRecordRetentionConfig::default();
23976        shared.retire_unavailable_session_records().await;
23977
23978        assert_eq!(
23979            retention_remaining_record_ids(&shared),
23980            owned(&[
23981                "a1", "a2", "a3", "b1", "b2", "b3",
23982                "decoy-live", "decoy-pending", "decoy-dormant",
23983            ]),
23984        );
23985        assert!(retention_removed_record_ids(&shared).is_empty());
23986        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23987
23988        drop(shared);
23989        drop(runtime);
23990        std::fs::remove_dir_all(root).unwrap();
23991    }
23992
23993    #[test]
23994    fn session_record_context_export_selects_one_exact_history_identity() {
23995        let candidates = vec![
23996            HistoryCandidateSummary {
23997                id: "candidate-other".to_owned(),
23998                session_id_hint: "provider-other".to_owned(),
23999                modified_at_unix_ms: Some(1),
24000            },
24001            HistoryCandidateSummary {
24002                id: "candidate-exact".to_owned(),
24003                session_id_hint: "provider-exact".to_owned(),
24004                modified_at_unix_ms: Some(2),
24005            },
24006        ];
24007        assert_eq!(
24008            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24009            "candidate-exact",
24010        );
24011    }
24012
24013    #[test]
24014    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24015        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24016            id: id.to_owned(),
24017            session_id_hint: hint.to_owned(),
24018            modified_at_unix_ms: None,
24019        };
24020        let missing = select_history_candidate(
24021            &[candidate("candidate-other", "provider-other")],
24022            Some("provider-exact"),
24023        )
24024        .unwrap_err();
24025        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24026        let ambiguous = select_history_candidate(
24027            &[
24028                candidate("candidate-a", "provider-exact"),
24029                candidate("candidate-b", "provider-exact"),
24030            ],
24031            Some("provider-exact"),
24032        )
24033        .unwrap_err();
24034        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24035    }
24036
24037    /// The PTY-sidecar-observation shape (no fleet provider today, but
24038    /// capability-keyed rather than vendor-locked) never carries a verified
24039    /// `provider_session`; its record-bound export instead falls back to
24040    /// "exactly one discovered candidate" (see
24041    /// `export_context_pack_for_session_record_inner`). This is the same
24042    /// ambiguity refusal as the identity-filtered path, just without an
24043    /// identity to filter by.
24044    #[test]
24045    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24046        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24047            id: id.to_owned(),
24048            session_id_hint: hint.to_owned(),
24049            modified_at_unix_ms: None,
24050        };
24051        assert_eq!(
24052            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24053                .unwrap(),
24054            "candidate-sole",
24055        );
24056        let missing = select_history_candidate(&[], None).unwrap_err();
24057        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24058        let ambiguous = select_history_candidate(
24059            &[
24060                candidate("candidate-a", "sidecar-session-a"),
24061                candidate("candidate-b", "sidecar-session-b"),
24062            ],
24063            None,
24064        )
24065        .unwrap_err();
24066        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24067    }
24068
24069    #[test]
24070    fn session_record_context_export_revalidation_rejects_record_change() {
24071        let session = terminal_address(1);
24072        let identity = ProviderSessionIdentity {
24073            key: ProviderSessionKey::SessionId,
24074            id: "provider-exact".to_owned(),
24075            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24076        };
24077        let mut expected = record("claude", "record-context-export");
24078        expected.state = ManagedSessionState::Live;
24079        expected.provider_session = Some(identity.clone());
24080        expected.active_session = Some(session.clone());
24081        let binding = SessionBinding {
24082            workspace_id: session.workspace_id.clone(),
24083            generation: session.session.generation,
24084            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24085            pending_resume: None,
24086            record_id: Some(expected.record_id.clone()),
24087            managed_worktree_lease_id: None,
24088            environment_profile: None,
24089            bundle: None,
24090            context: None,
24091            materialization_id: None,
24092        };
24093        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24094        assert!(session_record_context_export_binding_is_exact(
24095            &expected,
24096            &expected,
24097            Some(&identity),
24098            &session,
24099            &binding,
24100            &expected.provider,
24101            &current_root,
24102            false,
24103        ));
24104        let mut changed = expected.clone();
24105        changed.updated_at_unix_ms += 1;
24106        assert!(!session_record_context_export_binding_is_exact(
24107            &expected,
24108            &changed,
24109            Some(&identity),
24110            &session,
24111            &binding,
24112            &expected.provider,
24113            &current_root,
24114            false,
24115        ));
24116        // A same-instant timestamp bump alone (state and active_session
24117        // otherwise unchanged) is not the clean-detachment transition
24118        // either, even with the reactive flag on: only the exact Live ->
24119        // Dormant + active_session cleared shape qualifies.
24120        assert!(!session_record_context_export_binding_is_exact(
24121            &expected,
24122            &changed,
24123            Some(&identity),
24124            &session,
24125            &binding,
24126            &expected.provider,
24127            &current_root,
24128            true,
24129        ));
24130        let mut detached = expected.clone();
24131        detached.state = ManagedSessionState::Dormant;
24132        detached.active_session = None;
24133        detached.updated_at_unix_ms += 1;
24134        assert!(!session_record_context_export_binding_is_exact(
24135            &expected,
24136            &detached,
24137            Some(&identity),
24138            &session,
24139            &binding,
24140            &expected.provider,
24141            &current_root,
24142            false,
24143        ));
24144        assert!(session_record_context_export_binding_is_exact(
24145            &expected,
24146            &detached,
24147            Some(&identity),
24148            &session,
24149            &binding,
24150            &expected.provider,
24151            &current_root,
24152            true,
24153        ));
24154    }
24155
24156    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24157    /// for a record whose provider never carries a verified identity (the
24158    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24159    /// declares one today): `identity` is `None` throughout, and the
24160    /// exactness check falls back to comparing `provider_session` itself,
24161    /// which must still be `None` on both sides.
24162    #[test]
24163    fn session_record_context_export_binding_is_exact_without_identity() {
24164        let session = terminal_address(1);
24165        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24166        expected.state = ManagedSessionState::Live;
24167        expected.active_session = Some(session.clone());
24168        let binding = SessionBinding {
24169            workspace_id: session.workspace_id.clone(),
24170            generation: session.session.generation,
24171            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24172            pending_resume: None,
24173            record_id: Some(expected.record_id.clone()),
24174            managed_worktree_lease_id: None,
24175            environment_profile: None,
24176            bundle: None,
24177            context: None,
24178            materialization_id: None,
24179        };
24180        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24181        assert!(session_record_context_export_binding_is_exact(
24182            &expected,
24183            &expected,
24184            None,
24185            &session,
24186            &binding,
24187            &expected.provider,
24188            &current_root,
24189            false,
24190        ));
24191        // A record that gained an identity between the initial lookup and
24192        // this recheck no longer matches the `None` this shape expects to
24193        // keep seeing -- treated the same as any other mid-export drift.
24194        let mut drifted = expected.clone();
24195        drifted.provider_session = Some(ProviderSessionIdentity {
24196            key: ProviderSessionKey::SessionId,
24197            id: "unexpected-identity".to_owned(),
24198            transcript_path: None,
24199        });
24200        assert!(!session_record_context_export_binding_is_exact(
24201            &expected,
24202            &drifted,
24203            None,
24204            &session,
24205            &binding,
24206            &expected.provider,
24207            &current_root,
24208            false,
24209        ));
24210    }
24211
24212    #[test]
24213    fn session_record_context_export_preflight_requires_live_usable_source() {
24214        let mut source = record("claude", "record-context-export-preflight");
24215        source.state = ManagedSessionState::Live;
24216        assert!(session_record_context_export_source_is_usable(
24217            &source,
24218            &SessionStatus::Running,
24219            false,
24220        ));
24221        assert!(session_record_context_export_source_is_usable(
24222            &source,
24223            &SessionStatus::Exited { exit_code: Some(0) },
24224            false,
24225        ));
24226
24227        source.state = ManagedSessionState::Dormant;
24228        assert!(!session_record_context_export_source_is_usable(
24229            &source,
24230            &SessionStatus::Running,
24231            false,
24232        ));
24233        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24234        // the only caller that may treat a Dormant, cleanly-exited record as
24235        // still usable — the on-demand path above never does.
24236        assert!(session_record_context_export_source_is_usable(
24237            &source,
24238            &SessionStatus::Exited { exit_code: Some(0) },
24239            true,
24240        ));
24241        assert!(!session_record_context_export_source_is_usable(
24242            &source,
24243            &SessionStatus::Exited { exit_code: Some(0) },
24244            false,
24245        ));
24246        source.state = ManagedSessionState::Live;
24247        assert!(!session_record_context_export_source_is_usable(
24248            &source,
24249            &SessionStatus::Starting,
24250            false,
24251        ));
24252    }
24253
24254    #[test]
24255    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24256        let catalog = active_registry().unwrap();
24257        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24258        let workspace = WorkspaceConfig::new(
24259            WorkspaceId::new("primary").unwrap(),
24260            std::env::current_dir().unwrap(),
24261        )
24262        .unwrap();
24263        let shared = NodeShared::new(
24264            handle,
24265            "fixture-token".to_owned(),
24266            NodeId::new("node-context-export-conflict").unwrap(),
24267            vec![workspace],
24268            vec![agent("claude")],
24269        );
24270        let history = HistorySessionRecord {
24271            session_id: "provider-exact".to_owned(),
24272            title: None,
24273            cwd: None,
24274            model: None,
24275            message_count: 1,
24276            completed_turn_count: None,
24277            total_tokens: 0,
24278            messages: vec![gate4agent_types::HistoryMessageRecord {
24279                role: gate4agent_types::HistoryMessageRole::User,
24280                text: "bounded context".to_owned(),
24281            }],
24282        };
24283        let pack = NodeContextPack::export(
24284            ContextPackLineageReceipt {
24285                source_node_id: shared.node_id.clone(),
24286                source_session: terminal_address(1),
24287                source_provider: agent("claude"),
24288            },
24289            &history,
24290        )
24291        .unwrap();
24292        let context_id = pack.receipt().id.clone();
24293        let session = terminal_address(1);
24294        let identity = ProviderSessionIdentity {
24295            key: ProviderSessionKey::SessionId,
24296            id: history.session_id.clone(),
24297            transcript_path: None,
24298        };
24299        let mut expected = record("claude", "record-context-export-final-conflict");
24300        expected.state = ManagedSessionState::Live;
24301        expected.provider_session = Some(identity.clone());
24302        expected.active_session = Some(session.clone());
24303        let mut changed = expected.clone();
24304        changed.updated_at_unix_ms += 1;
24305        shared.insert_record(changed).unwrap();
24306
24307        let error = shared
24308            .commit_context_pack_for_session_record(
24309                &expected,
24310                Some(&identity),
24311                &session,
24312                "candidate-exact",
24313                &history,
24314                pack,
24315                false,
24316            )
24317            .unwrap_err();
24318
24319        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24320        assert!(shared
24321            .context_catalog
24322            .read()
24323            .unwrap_or_else(|poisoned| poisoned.into_inner())
24324            .get(&context_id)
24325            .is_none());
24326    }
24327
24328    #[tokio::test]
24329    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24330        let root = temporary_workspace_root("session-task-binding");
24331        std::fs::create_dir_all(&root).unwrap();
24332        let state_path = root.join("node-state.json");
24333        let catalog = active_registry().unwrap();
24334        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24335        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24336        let node_id = NodeId::new("node-1").unwrap();
24337        let mut shared = NodeShared::new(
24338            handle,
24339            "fixture-token".to_owned(),
24340            node_id.clone(),
24341            vec![workspace],
24342            vec![agent("claude")],
24343        );
24344        shared.state_path = Some(state_path.clone());
24345        let record = record("claude", "sr-task");
24346        let record_id = record.record_id.clone();
24347        shared.insert_record(record).unwrap();
24348        shared.persist_state().unwrap();
24349        shared
24350            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24351            .unwrap();
24352
24353        let minted = process_request_inner(
24354            &shared,
24355            77,
24356            ClientRole::Operator,
24357            NodeRequest::SetSessionTask {
24358                record_id: record_id.clone(),
24359                expected_revision: 0,
24360                target: SessionTaskTargetV1::New,
24361            },
24362        )
24363        .await
24364        .unwrap();
24365        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24366            panic!("session task mutation returned another response");
24367        };
24368        let minted_binding = minted.task_binding.clone().unwrap();
24369        let minted_task_id = minted_binding.task_id.clone().unwrap();
24370        assert_eq!(minted_binding.revision, 1);
24371        assert!(minted_task_id.as_str().starts_with("task-"));
24372        assert_eq!(minted_task_id.as_str().len(), 29);
24373
24374        let event_count = shared.current_sequence();
24375        let repeated = process_request_inner(
24376            &shared,
24377            77,
24378            ClientRole::Operator,
24379            NodeRequest::SetSessionTask {
24380                record_id: record_id.clone(),
24381                expected_revision: 1,
24382                target: SessionTaskTargetV1::Existing {
24383                    task_id: minted_task_id.clone(),
24384                },
24385            },
24386        )
24387        .await
24388        .unwrap();
24389        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24390        assert_eq!(shared.current_sequence(), event_count);
24391
24392        let stale = process_request_inner(
24393            &shared,
24394            77,
24395            ClientRole::Operator,
24396            NodeRequest::SetSessionTask {
24397                record_id: record_id.clone(),
24398                expected_revision: 0,
24399                target: SessionTaskTargetV1::Existing {
24400                    task_id: minted_task_id,
24401                },
24402            },
24403        )
24404        .await
24405        .unwrap_err();
24406        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24407
24408        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24409        let assigned = process_request_inner(
24410            &shared,
24411            77,
24412            ClientRole::Operator,
24413            NodeRequest::SetSessionTask {
24414                record_id: record_id.clone(),
24415                expected_revision: 1,
24416                target: SessionTaskTargetV1::Existing {
24417                    task_id: assigned_id.clone(),
24418                },
24419            },
24420        )
24421        .await
24422        .unwrap();
24423        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24424            panic!("session task assignment returned another response");
24425        };
24426        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24427        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24428
24429        let cleared = process_request_inner(
24430            &shared,
24431            77,
24432            ClientRole::Operator,
24433            NodeRequest::SetSessionTask {
24434                record_id: record_id.clone(),
24435                expected_revision: 2,
24436                target: SessionTaskTargetV1::Clear,
24437            },
24438        )
24439        .await
24440        .unwrap();
24441        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24442            panic!("session task clear returned another response");
24443        };
24444        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24445        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24446        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24447        assert_eq!(
24448            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24449            cleared.updated_at_unix_ms,
24450        );
24451
24452        let persisted = std::fs::read(&state_path).unwrap();
24453        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24454        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24455        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24456        std::fs::remove_dir_all(root).unwrap();
24457    }
24458
24459    #[test]
24460    fn session_task_capability_strips_snapshots_events_and_replies() {
24461        let mut bound = record("claude", "sr-task-projection");
24462        bound.updated_at_unix_ms = 2;
24463        bound.task_binding = Some(SessionTaskBindingV1 {
24464            revision: 1,
24465            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24466            changed_at_unix_ms: 2,
24467        });
24468        let request = NodeRequest::SetSessionTask {
24469            record_id: bound.record_id.clone(),
24470            expected_revision: 1,
24471            target: SessionTaskTargetV1::Clear,
24472        };
24473        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24474            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24475        }));
24476        assert!(!request_uses_unnegotiated_capability(
24477            &request,
24478            &baseline_capabilities().unwrap(),
24479        ));
24480        assert!(request_uses_unnegotiated_capability(&request, &[]));
24481
24482        let mut snapshot = terminal_test_shared().snapshot();
24483        snapshot.session_records.push(bound.clone());
24484        let mut reply = ResponseEnvelope {
24485            request_id: 1,
24486            result: Ok(NodeResponse::Resync {
24487                event_sequence: 1,
24488                oldest_available_sequence: 1,
24489                snapshot,
24490                events: vec![NodeEventEnvelope {
24491                    sequence: 1,
24492                    event: NodeEvent::SessionRecordUpserted {
24493                        record: bound.clone(),
24494                    },
24495                }],
24496            }),
24497        };
24498        project_response_without_session_task_binding(&mut reply);
24499        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24500            panic!("task projection changed the response kind");
24501        };
24502        assert!(snapshot.session_records[0].task_binding.is_none());
24503        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24504            panic!("task projection changed the event kind");
24505        };
24506        assert!(record.task_binding.is_none());
24507
24508        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24509            sequence: 2,
24510            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24511        });
24512        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24513            panic!("task event projection changed the event kind");
24514        };
24515        assert!(record.task_binding.is_none());
24516
24517        let mut direct = ResponseEnvelope {
24518            request_id: 2,
24519            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24520        };
24521        project_response_without_session_task_binding(&mut direct);
24522        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24523            panic!("task reply projection changed the response kind");
24524        };
24525        assert!(record.task_binding.is_none());
24526    }
24527
24528    #[test]
24529    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24530        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24531        let workspace = WorkspaceConfig::new(
24532            WorkspaceId::new("primary").unwrap(),
24533            std::env::current_dir().unwrap(),
24534        )
24535        .unwrap();
24536        let config = NodeServerConfig::new(
24537            DEFAULT_NODE_ENDPOINT,
24538            "fixture-token",
24539            NodeId::new("node-1").unwrap(),
24540            [workspace],
24541        )
24542        .unwrap();
24543        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24544    }
24545
24546    #[cfg(all(feature = "fixture", windows))]
24547    #[test]
24548    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24549        let root = temporary_workspace_root("provider-bundle-argv-hold");
24550        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24551        std::fs::create_dir_all(&root).unwrap();
24552        std::fs::create_dir_all(&outside).unwrap();
24553        let proof_path = root.join("provider.proof");
24554        let release_signal = root.join("release.signal");
24555        let config = NodeServerConfig::new(
24556            format!(
24557                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24558                std::process::id(),
24559            ),
24560            "fixture-token",
24561            NodeId::new("provider-bundle-hold-node").unwrap(),
24562            [WorkspaceConfig::new(
24563                WorkspaceId::new("primary").unwrap(),
24564                std::env::current_dir().unwrap(),
24565            )
24566            .unwrap()],
24567        )
24568        .unwrap()
24569        .with_spawn_profiles(SpawnProfileRegistry::new([
24570            crate::protocol::SpawnProfileDefaults {
24571                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24572                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24573                    .unwrap(),
24574                provider: agent("kimi"),
24575                mode: SessionMode::Pty,
24576                terminal_size: gate4agent_types::TerminalSize {
24577                    rows: 24,
24578                    columns: 80,
24579                },
24580                prompt: None,
24581                bundle_id: None,
24582                context_id: None,
24583                environment_profile_id: None,
24584            },
24585        ])
24586        .unwrap());
24587        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24588            config,
24589            agent("kimi"),
24590            proof_path.clone(),
24591            release_signal.clone(),
24592        )
24593        .unwrap();
24594        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24595
24596        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24597            agent("kimi"),
24598            proof_path.clone(),
24599            Some(release_signal.clone()),
24600        )
24601        .unwrap();
24602        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24603        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24604        let release_wait = wrapper
24605            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24606            .unwrap();
24607        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24608        assert!(proof_write < release_wait);
24609        assert!(release_wait < fixture_ready);
24610        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24611        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24612        assert_eq!(
24613            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24614            &[
24615                proof_path.to_string_lossy().into_owned(),
24616                release_signal.to_string_lossy().into_owned(),
24617            ],
24618        );
24619
24620        let error = NodeServer::provider_bundle_argv_fixture_spec(
24621            agent("kimi"),
24622            proof_path,
24623            Some(outside.join("release.signal")),
24624        )
24625        .unwrap_err();
24626        assert!(matches!(
24627            error,
24628            NodeServerError::Registry(message)
24629                if message == "provider bundle argv release path must share the proof parent"
24630        ));
24631        drop(server);
24632        std::fs::remove_dir_all(root).unwrap();
24633        std::fs::remove_dir_all(outside).unwrap();
24634    }
24635
24636    #[cfg(feature = "fixture")]
24637    #[test]
24638    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24639        let root = temporary_workspace_root("context-fixture-registry");
24640        std::fs::create_dir_all(&root).unwrap();
24641        let proof_path = root.join("context.proof");
24642        let config = NodeServerConfig::new(
24643            format!(
24644                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24645                std::process::id(),
24646            ),
24647            "fixture-token",
24648            NodeId::new("context-fixture-node").unwrap(),
24649            [WorkspaceConfig::new(
24650                WorkspaceId::new("primary").unwrap(),
24651                std::env::current_dir().unwrap(),
24652            )
24653            .unwrap()],
24654        )
24655        .unwrap();
24656        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24657        let mut enabled = server
24658            .shared
24659            .enabled_providers
24660            .iter()
24661            .map(AgentId::as_str)
24662            .collect::<Vec<_>>();
24663        enabled.sort_unstable();
24664        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24665        for provider in &server.shared.enabled_providers {
24666            assert!(server
24667                .shared
24668                .provider_adapter_contracts
24669                .iter()
24670                .any(|contract| {
24671                    &contract.provider == provider
24672                        && contract.family == AdapterFamily::History
24673                }));
24674        }
24675        drop(server);
24676        std::fs::remove_dir_all(root).unwrap();
24677    }
24678
24679    #[cfg(feature = "fixture")]
24680    #[test]
24681    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24682        let root = temporary_workspace_root("monitoring-context-fixture");
24683        std::fs::create_dir_all(&root).unwrap();
24684        let proof_path = root.join("context.proof");
24685        let standard = NodeServer::context_pack_fixture_catalog(
24686            proof_path.clone(),
24687            None,
24688            false,
24689        ).unwrap();
24690        let monitoring = NodeServer::context_pack_fixture_catalog(
24691            proof_path.clone(),
24692            None,
24693            true,
24694        ).unwrap();
24695        let claude = AgentId::new("claude").unwrap();
24696        let standard_claude = standard.get(&claude).unwrap();
24697        let monitoring_claude = monitoring.get(&claude).unwrap();
24698        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24699        // standard nor the monitoring fixture declares a hook adapter
24700        // anymore.
24701        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24702        assert_eq!(
24703            monitoring_claude.capabilities.adapters.history,
24704            standard_claude.capabilities.adapters.history,
24705        );
24706        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24707        let codex = AgentId::new("codex").unwrap();
24708        let standard_codex = standard.get(&codex).unwrap();
24709        let monitoring_codex = monitoring.get(&codex).unwrap();
24710        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24711        assert_eq!(
24712            monitoring_codex.capabilities.adapters.history,
24713            standard_codex.capabilities.adapters.history,
24714        );
24715        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24716        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24717            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24718            .unwrap();
24719        for required in [
24720            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24721            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24722            "@('role', 'text')",
24723            "g4a-private-provider-session-canary",
24724            "private-provider-login-identity",
24725            "$rawProof",
24726            "$env:GATE4AGENT_HOOK_URL",
24727            "g4a-private-provider-session-canary",
24728        ] {
24729            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24730        }
24731        for provider in ["grok", "kimi"] {
24732            let provider = AgentId::new(provider).unwrap();
24733            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24734        }
24735
24736        let config = NodeServerConfig::new(
24737            format!(
24738                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24739                std::process::id(),
24740            ),
24741            "fixture-token",
24742            NodeId::new("monitoring-context-fixture-node").unwrap(),
24743            [WorkspaceConfig::new(
24744                WorkspaceId::new("primary").unwrap(),
24745                std::env::current_dir().unwrap(),
24746            )
24747            .unwrap()],
24748        )
24749        .unwrap();
24750        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24751        assert!(server.shared.fixture_semantic_hook_policy);
24752        drop(server);
24753        std::fs::remove_dir_all(root).unwrap();
24754    }
24755
24756    #[cfg(feature = "fixture")]
24757    #[test]
24758    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24759        let root = temporary_workspace_root("kimi-context-only-fixture");
24760        std::fs::create_dir_all(&root).unwrap();
24761        let proof_path = root.join("context.proof");
24762        let standard = NodeServer::context_pack_fixture_catalog(
24763            proof_path.clone(),
24764            None,
24765            false,
24766        ).unwrap();
24767        let kimi = AgentId::new("kimi").unwrap();
24768        let context_only = NodeServer::context_pack_fixture_catalog(
24769            proof_path,
24770            Some(&kimi),
24771            false,
24772        )
24773        .unwrap();
24774
24775        let codex = AgentId::new("codex").unwrap();
24776        assert_eq!(
24777            standard.get(&codex).unwrap().launch.fixed_args,
24778            context_only.get(&codex).unwrap().launch.fixed_args,
24779        );
24780        let script = context_only
24781            .get(&kimi)
24782            .unwrap()
24783            .launch
24784            .fixed_args
24785            .iter()
24786            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24787            .expect("Kimi context-only validation script is missing");
24788        for required in [
24789            "$bundleArgs.Count -ne 0",
24790            "GATE4AGENT_CONTEXT_ROOT",
24791            "context-pack.json",
24792            "$entries.Count -ne 1",
24793            "g4a-context-pack-v1",
24794            "$document.PSObject.Properties['cwd']",
24795            "'claude', 'codex', 'grok', 'kimi'",
24796            "$hasUser",
24797            "$hasAssistant",
24798            "SHA256",
24799            "context-only",
24800        ] {
24801            assert!(script.contains(required), "missing validation: {required}");
24802        }
24803        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24804            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24805        }
24806        assert!(!standard
24807            .get(&kimi)
24808            .unwrap()
24809            .launch
24810            .fixed_args
24811            .iter()
24812            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24813
24814        std::fs::remove_dir_all(root).unwrap();
24815    }
24816
24817    #[cfg(all(feature = "fixture", windows))]
24818    #[tokio::test]
24819    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24820        let root = temporary_workspace_root("kimi-context-only-spawn");
24821        let workspace_root = root.join("workspace");
24822        std::fs::create_dir_all(&workspace_root).unwrap();
24823        let materialization_root = root.join("private-materializations");
24824        let proof_path = root.join("context.proof");
24825        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24826        let workspace_id = WorkspaceId::new("primary").unwrap();
24827        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24828        let endpoint = format!(
24829            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24830            std::process::id(),
24831            unix_time_ms(),
24832        );
24833        let spawn_profiles = SpawnProfileRegistry::new([
24834            crate::protocol::SpawnProfileDefaults {
24835                profile_id: profile_id.clone(),
24836                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24837                provider: agent("kimi"),
24838                mode: SessionMode::Pty,
24839                terminal_size: gate4agent_types::TerminalSize {
24840                    rows: 30,
24841                    columns: 120,
24842                },
24843                prompt: None,
24844                bundle_id: None,
24845                context_id: None,
24846                environment_profile_id: None,
24847            },
24848        ])
24849        .unwrap();
24850        let mut config = NodeServerConfig::new(
24851            &endpoint,
24852            "fixture-token",
24853            node_id.clone(),
24854            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24855        )
24856        .unwrap()
24857        .with_spawn_profiles(spawn_profiles)
24858        .with_session_environment_materialization(
24859            materialization_root,
24860            Arc::new(FixtureSecretResolver {
24861                deny: Arc::new(AtomicBool::new(false)),
24862            }),
24863        )
24864        .unwrap();
24865        config.fixture_raw_pty_runtime = true;
24866        let server = NodeServer::new_context_only_proof_fixture(
24867            config,
24868            agent("kimi"),
24869            proof_path.clone(),
24870        )
24871        .unwrap();
24872        let shared = Arc::clone(&server.shared);
24873        let shutdown = server.shutdown_handle();
24874
24875        let history = HistorySessionRecord {
24876            session_id: "context-only-source-history".to_owned(),
24877            title: None,
24878            cwd: None,
24879            model: None,
24880            message_count: 2,
24881            completed_turn_count: None,
24882            total_tokens: 0,
24883            messages: vec![
24884                gate4agent_types::HistoryMessageRecord {
24885                    role: gate4agent_types::HistoryMessageRole::User,
24886                    text: "continue the bounded parent run through native C2".to_owned(),
24887                },
24888                gate4agent_types::HistoryMessageRecord {
24889                    role: gate4agent_types::HistoryMessageRole::Assistant,
24890                    text: "the exact parent context is ready for the child run".to_owned(),
24891                },
24892            ],
24893        };
24894        let pack = NodeContextPack::export(
24895            ContextPackLineageReceipt {
24896                source_node_id: node_id.clone(),
24897                source_session: SessionAddress {
24898                    workspace_id: workspace_id.clone(),
24899                    session: SessionKey {
24900                        instance_id: AgentInstanceId(71),
24901                        generation: SessionGeneration(1),
24902                    },
24903                },
24904                source_provider: agent("codex"),
24905            },
24906            &history,
24907        )
24908        .unwrap();
24909        let expected_context_hash = digest(&SHA256, pack.bytes())
24910            .as_ref()
24911            .iter()
24912            .map(|byte| format!("{byte:02x}"))
24913            .collect::<String>();
24914        let context = shared
24915            .context_catalog
24916            .write()
24917            .unwrap_or_else(|poisoned| poisoned.into_inner())
24918            .insert(pack)
24919            .unwrap();
24920        let spec = SpawnSpec {
24921            target: crate::protocol::SpawnTarget {
24922                node_id,
24923                workspace_id: workspace_id.clone(),
24924                worktree_id: None,
24925            },
24926            profile_id,
24927            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24928                "target-kimi-r1",
24929            ).unwrap(),
24930            overrides: crate::protocol::SpawnOverrides {
24931                context_id: crate::protocol::SpawnOverride::Set {
24932                    value: context.id.clone(),
24933                },
24934                ..crate::protocol::SpawnOverrides::default()
24935            },
24936            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24937            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24938            required_capabilities: SpawnRequiredCapabilities::default(),
24939        };
24940        let resolved = match shared.resolve_spawn_spec(&spec) {
24941            Ok(resolved) => resolved,
24942            Err(error) => panic!(
24943                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24944                error.code,
24945                error.message,
24946            ),
24947        };
24948        let resolved_context = match shared.resolve_context(&resolved) {
24949            Ok(Some(resolved_context)) => resolved_context,
24950            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24951            Err(error) => panic!(
24952                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24953                error.code,
24954                error.message,
24955            ),
24956        };
24957        assert_eq!(resolved_context, context);
24958        let runtime_policy = match shared
24959            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24960            .await
24961        {
24962            Ok(runtime_policy) => runtime_policy,
24963            Err(error) => panic!(
24964                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24965                error.code,
24966                error.message,
24967            ),
24968        };
24969        assert!(runtime_policy.raw_pty_lifecycle);
24970
24971        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24972        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24973            agent("kimi"),
24974            TransportKind::Pty,
24975            vec![EnvMutation {
24976                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24977                value: Some(OsString::from("must-remain-rejected")),
24978            }],
24979        )
24980        .unwrap();
24981        assert_eq!(
24982            launch_control
24983                .install_native_launch_environment_overlay(
24984                    AgentInstanceId(72),
24985                    generic_overlay,
24986                )
24987                .unwrap_err(),
24988            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24989        );
24990
24991        let diagnostic_address = SessionAddress {
24992            workspace_id: workspace_id.clone(),
24993            session: SessionKey {
24994                instance_id: AgentInstanceId(73),
24995                generation: SessionGeneration(1),
24996            },
24997        };
24998        let (diagnostic_overlay, diagnostic_materialization) = match shared
24999            .prepare_session_materialization(
25000                &diagnostic_address,
25001                &agent("kimi"),
25002                SessionMode::Pty,
25003                None,
25004                None,
25005                Some(&resolved_context),
25006                None,
25007            )
25008        {
25009            Ok(Some(prepared)) => prepared,
25010            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25011            Err(error) => panic!(
25012                "Kimi context-only materialization rejected: exact_code={:?} message={}",
25013                error.code,
25014                error.message,
25015            ),
25016        };
25017        let diagnostic_overlay = diagnostic_overlay
25018            .expect("Kimi context-only materialization omitted its environment overlay");
25019        assert!(matches!(
25020            &diagnostic_overlay,
25021            PreparedNativeLaunchOverlay::Environment(_),
25022        ));
25023        match shared.install_prepared_launch_overlay(
25024            diagnostic_address.session.instance_id,
25025            diagnostic_overlay,
25026        ) {
25027            Ok(None) => {}
25028            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25029            Err(error) => panic!(
25030                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25031                error.code,
25032                error.message,
25033            ),
25034        }
25035        assert!(launch_control.clear_native_instance_launch_overlay(
25036            diagnostic_address.session.instance_id,
25037        ));
25038        drop(diagnostic_materialization);
25039        assert!(shared
25040            .materializations
25041            .lock()
25042            .unwrap_or_else(|poisoned| poisoned.into_inner())
25043            .is_empty());
25044
25045        let node_task = tokio::spawn(server.run());
25046        let ready_client = timeout(Duration::from_secs(5), async {
25047            loop {
25048                match gate4agent_node_wire::LocalNodeClient::connect(
25049                    &endpoint,
25050                    &shared.node_id,
25051                    ClientRole::Observer,
25052                    "fixture-token",
25053                )
25054                .await
25055                {
25056                    Ok(client) => break client,
25057                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25058                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25059                }
25060            }
25061        })
25062        .await
25063        .expect("Kimi context-only Node did not become ready");
25064        assert_eq!(
25065            ready_client
25066                .hello()
25067                .snapshot
25068                .provider_runtime_statuses
25069                .iter()
25070                .find(|status| status.provider().as_str() == "kimi")
25071                .expect("Kimi runtime status is missing")
25072                .mode(),
25073            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25074        );
25075        drop(ready_client);
25076
25077        let receipt = match shared.spawn_from_spec(spec).await {
25078            Ok(receipt) => receipt,
25079            Err(error) => panic!(
25080                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25081                error.code,
25082                error.message,
25083            ),
25084        };
25085        assert_eq!(receipt.provider, agent("kimi"));
25086        assert_eq!(receipt.context.as_ref(), Some(&context));
25087        assert!(receipt.context_binding_is_valid());
25088        timeout(Duration::from_secs(5), async {
25089            while !proof_path.is_file() {
25090                sleep(Duration::from_millis(10)).await;
25091            }
25092        })
25093        .await
25094        .expect("Kimi context-only fixture did not write its proof");
25095
25096        let proof = std::fs::read_to_string(&proof_path).unwrap();
25097        let lines = proof.lines().collect::<Vec<_>>();
25098        assert_eq!(lines.len(), 7);
25099        assert_eq!(lines[0], "context-only");
25100        assert!(Path::new(lines[1]).is_absolute());
25101        assert_ne!(
25102            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25103            std::fs::canonicalize(&workspace_root).unwrap(),
25104        );
25105        assert_eq!(
25106            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25107            std::fs::canonicalize(&workspace_root).unwrap(),
25108        );
25109        assert_eq!(lines[3], expected_context_hash);
25110        assert_eq!(lines[4], "g4a-context-pack-v1");
25111        assert_eq!(lines[5], "codex");
25112        assert_eq!(lines[6], "2");
25113        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25114            session.instance_id == receipt.session.session.instance_id
25115                && session.agent_id.as_str() == "kimi"
25116        }));
25117
25118        shutdown.request_shutdown().await.unwrap();
25119        timeout(Duration::from_secs(5), node_task)
25120            .await
25121            .unwrap()
25122            .unwrap()
25123            .unwrap();
25124        drop(shutdown);
25125        drop(shared);
25126        std::fs::remove_dir_all(root).unwrap();
25127    }
25128
25129    #[cfg(feature = "fixture")]
25130    #[test]
25131    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25132        let root = temporary_workspace_root("clean-exit-constructor");
25133        std::fs::create_dir_all(&root).unwrap();
25134        let config = construction_test_config("clean-exit-constructor")
25135            .with_state_path(root.join("node-state.json"))
25136            .unwrap();
25137        let server = NodeServer::new_clean_exit_fixture(
25138            config,
25139            root.clone(),
25140            root.join("started.marker"),
25141            root.join("release.signal"),
25142        )
25143        .unwrap();
25144        assert_eq!(
25145            server
25146                .shared
25147                .enabled_providers
25148                .iter()
25149                .map(AgentId::as_str)
25150                .collect::<Vec<_>>(),
25151            ["claude"],
25152        );
25153        drop(server);
25154        std::fs::remove_dir_all(root).unwrap();
25155    }
25156
25157    #[test]
25158    fn production_registry_provider_contract_manifest_is_exact() {
25159        let registry = active_registry().unwrap();
25160        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25161        assert_eq!(
25162            providers
25163                .iter()
25164                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25165                .collect::<Vec<_>>(),
25166            vec![
25167                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25168                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25169                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25170                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25171            ]
25172        );
25173        assert_eq!(
25174            adapters
25175                .iter()
25176                .map(|contract| (
25177                    contract.provider.as_str(),
25178                    contract.family,
25179                    contract.adapter_id.as_str(),
25180                    contract.revision.as_str(),
25181                ))
25182                .collect::<Vec<_>>(),
25183            vec![
25184                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25185                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25186                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25187                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25188                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25189                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25190                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25191                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25192                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25193                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25194                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25195                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25196                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25197                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25198                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25199                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25200                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25201                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25202                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25203                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25204                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25205                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25206                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25207            ]
25208        );
25209    }
25210
25211    fn construction_test_config(name: &str) -> NodeServerConfig {
25212        let workspace = WorkspaceConfig::new(
25213            WorkspaceId::new("primary").unwrap(),
25214            std::env::current_dir().unwrap(),
25215        )
25216        .unwrap();
25217        NodeServerConfig::new(
25218            format!(r"\\.\pipe\gate4agent-{name}"),
25219            "fixture-token",
25220            NodeId::new(name).unwrap(),
25221            [workspace],
25222        )
25223        .unwrap()
25224    }
25225
25226    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25227        let adapter = AdapterBinding::new(
25228            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25229            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25230            gate4agent_types::AdapterVerification::SyntheticFixture,
25231        )
25232        .unwrap();
25233        let agent_ids = ["claude", "codex", "kimi"]
25234            .into_iter()
25235            .map(|agent_id| AgentId::new(agent_id).unwrap())
25236            .collect::<Vec<_>>();
25237        let adapters = gate4agent_catalog::AdapterRegistry::new(
25238            [
25239                AdapterFamily::PtySemantic,
25240                AdapterFamily::Pipe,
25241                AdapterFamily::OneShot,
25242                AdapterFamily::Acp,
25243                AdapterFamily::Hook,
25244                AdapterFamily::ManagedHook,
25245                AdapterFamily::History,
25246                AdapterFamily::Resume,
25247                AdapterFamily::SessionOptions,
25248                AdapterFamily::CapabilityProbe,
25249            ]
25250            .into_iter()
25251            .map(|family| gate4agent_catalog::AdapterDescriptor {
25252                family,
25253                binding: adapter.clone(),
25254                agents: agent_ids.clone(),
25255            }),
25256        )
25257        .unwrap();
25258        let specs = agent_ids
25259            .iter()
25260            .map(|agent_id| {
25261                let agent_id = agent_id.as_str();
25262                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25263                spec.revision =
25264                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25265                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25266                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25267                    adapter: adapter.clone(),
25268                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25269                    launch_override: None,
25270                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25271                });
25272                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25273                    adapter: adapter.clone(),
25274                    launch_override: None,
25275                });
25276                spec.capabilities.adapters.hook = Some(adapter.clone());
25277                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25278                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25279                spec.capabilities.adapters.history = Some(adapter.clone());
25280                spec.capabilities.adapters.resume = Some(adapter.clone());
25281                spec.capabilities.adapters.session_options = Some(adapter.clone());
25282                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25283                spec
25284            })
25285            .collect::<Vec<_>>();
25286        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25287    }
25288
25289    /// The real fleet's manifest is accepted by a real server construction.
25290    ///
25291    /// The count is asserted against `active_registry` itself rather than a
25292    /// literal, so this stays a test that construction succeeds for whatever
25293    /// the fleet is -- which is the property worth guarding -- instead of a
25294    /// second place the fleet size is written down and has to be kept in
25295    /// step with the first.
25296    #[test]
25297    fn node_server_construction_accepts_the_production_manifest() {
25298        let registry = active_registry().unwrap();
25299        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25300        assert_eq!(providers.len(), registry.iter().count());
25301        assert!(!providers.is_empty());
25302        let server = NodeServer::new_with_registry(
25303            construction_test_config("manifest-production-bound"),
25304            registry,
25305        )
25306        .unwrap();
25307        drop(server);
25308    }
25309
25310    #[test]
25311    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25312        let registry = maximum_size_provider_manifest_registry();
25313        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25314        assert_eq!(providers.len(), 3);
25315        assert_eq!(adapters.len(), 30);
25316        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25317        let error = match NodeServer::new_with_registry(
25318            construction_test_config("manifest-handshake-overflow"),
25319            registry,
25320        ) {
25321            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25322            Err(error) => error,
25323        };
25324        assert!(matches!(
25325            error,
25326            NodeServerError::ProviderContractManifest(message)
25327                if message.contains("authentication binding length")
25328                    && message.contains("8192-byte limit")
25329        ));
25330    }
25331
25332    #[test]
25333    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25334        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25335        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25336        let registry = AgentRegistry::new([spec]).unwrap();
25337        assert!(matches!(
25338            provider_contract_manifest(&registry),
25339            Err(NodeServerError::ProviderContractManifest(message))
25340                if message.contains("exceeds the 128-byte limit")
25341        ));
25342    }
25343
25344    #[test]
25345    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25346        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25347        spec.revision = "invalid revision".to_owned();
25348        let registry = AgentRegistry::new([spec]).unwrap();
25349        assert!(matches!(
25350            provider_contract_manifest(&registry),
25351            Err(NodeServerError::ProviderContractManifest(message))
25352                if message.contains("bounded lowercase ASCII")
25353        ));
25354    }
25355
25356    #[test]
25357    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25358        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25359        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25360        let conflicting_binding = builtin_registry()
25361            .get_by_id("codex")
25362            .unwrap()
25363            .capabilities
25364            .adapters
25365            .one_shot
25366            .clone()
25367            .unwrap();
25368        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25369        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25370        pipe.adapter = transport_binding;
25371        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25372        let registry = AgentRegistry::new([spec]).unwrap();
25373        assert!(matches!(
25374            provider_contract_manifest(&registry),
25375            Err(NodeServerError::ProviderContractManifest(message))
25376                if message.contains("conflicting OneShot adapter bindings")
25377        ));
25378    }
25379
25380    #[test]
25381    fn public_node_failures_expose_only_fixed_categories() {
25382        let provider = failure(
25383            NodeFailureCode::BackendOperationFailed,
25384            r"provider bearer-secret failed at C:\private\session.jsonl",
25385        );
25386        assert_eq!(provider.message, "backend-operation-failed");
25387        assert!(!provider.message.contains("bearer-secret"));
25388        assert!(!provider.message.contains(r"C:\private"));
25389        let persistence = persistence_failure(io::Error::new(
25390            io::ErrorKind::Other,
25391            r"state write failed at C:\private\state-v1.json",
25392        ));
25393        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25394        assert!(!persistence.message.contains(r"C:\private"));
25395
25396        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25397            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25398            assert_eq!(
25399                generic.to_string(),
25400                "node durable state failed: durable-state-load-failed",
25401            );
25402        }
25403    }
25404
25405    #[test]
25406    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25407        let sequence = b"\x1b[1;5D".to_vec();
25408        assert_eq!(
25409            terminal_bytes_action(sequence.clone()).unwrap(),
25410            InputAction::TerminalBytes(sequence),
25411        );
25412        assert_eq!(
25413            terminal_bytes_action(Vec::new()).unwrap_err().code,
25414            NodeFailureCode::InvalidRequest,
25415        );
25416        assert_eq!(
25417            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25418                .unwrap_err()
25419                .code,
25420            NodeFailureCode::InvalidRequest,
25421        );
25422    }
25423
25424    #[test]
25425    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25426        let root = temporary_workspace_root("exclusive-state-owner");
25427        std::fs::create_dir_all(&root).unwrap();
25428        let workspace = WorkspaceConfig::new(
25429            WorkspaceId::new("primary").unwrap(),
25430            &root,
25431        )
25432        .unwrap();
25433        let state_path = root.join("state-v1.json");
25434        let endpoint = format!(
25435            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25436            std::process::id(),
25437            std::time::SystemTime::now()
25438                .duration_since(std::time::UNIX_EPOCH)
25439                .unwrap()
25440                .as_nanos(),
25441        );
25442        let config = NodeServerConfig::new(
25443            endpoint,
25444            "fixture-token",
25445            NodeId::new("node-1").unwrap(),
25446            [workspace],
25447        )
25448        .unwrap()
25449        .with_state_path(&state_path)
25450        .unwrap();
25451
25452        let first = NodeServer::new(config.clone()).unwrap();
25453        let error = match NodeServer::new(config.clone()) {
25454            Ok(_) => panic!("a second node server acquired the same durable state path"),
25455            Err(error) => error,
25456        };
25457        assert!(matches!(
25458            &error,
25459            NodeServerError::DurableState(error)
25460                if error.kind() == io::ErrorKind::WouldBlock
25461        ));
25462        assert_eq!(
25463            error.to_string(),
25464            "node durable state failed: durable-state-lock-failed",
25465        );
25466        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25467        drop(first);
25468        let second = NodeServer::new(config).unwrap();
25469        drop(second);
25470        std::fs::remove_dir_all(root).unwrap();
25471    }
25472
25473    #[test]
25474    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25475        let catalog = active_registry().unwrap();
25476        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25477        let workspace_id = WorkspaceId::new("primary").unwrap();
25478        let workspace = WorkspaceConfig::new(
25479            workspace_id.clone(),
25480            std::env::current_dir().unwrap(),
25481        )
25482        .unwrap();
25483        let shared = NodeShared::new(
25484            handle,
25485            "fixture-token".to_owned(),
25486            NodeId::new("node-1").unwrap(),
25487            vec![workspace.clone()],
25488            vec![agent("claude"), agent("codex"), agent("kimi")],
25489        );
25490        let instance_id = AgentInstanceId(17);
25491        let generation = SessionGeneration(3);
25492        assert!(shared.address_for(instance_id, generation).is_none());
25493        shared.bind_session(instance_id, workspace_id.clone(), generation);
25494        let address = SessionAddress {
25495            workspace_id,
25496            session: SessionKey { instance_id, generation },
25497        };
25498        assert_eq!(
25499            shared.workspace_root(&address.workspace_id).unwrap(),
25500            workspace.canonical_root,
25501        );
25502        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25503        shared.remove_binding(&address);
25504        assert!(shared.address_for(instance_id, generation).is_none());
25505    }
25506
25507    #[test]
25508    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25509        let address = SessionAddress {
25510            workspace_id: WorkspaceId::new("primary").unwrap(),
25511            session: SessionKey {
25512                instance_id: AgentInstanceId(7),
25513                generation: SessionGeneration(2),
25514            },
25515        };
25516        let record_id = SessionRecordId::new("record-a").unwrap();
25517        let size = gate4agent_types::TerminalSize {
25518            rows: 24,
25519            columns: 80,
25520        };
25521        let address_requests = [
25522            NodeRequest::Resume {
25523                session: address.clone(),
25524                terminal_size: size,
25525                initial_prompt: None,
25526            },
25527            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25528            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25529            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25530            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25531            NodeRequest::TerminalControl {
25532                session: address.clone(),
25533                control: TerminalControl::Enter,
25534            },
25535            NodeRequest::Resize { session: address.clone(), size },
25536            NodeRequest::Interrupt { session: address.clone() },
25537            NodeRequest::Stop { session: address.clone(), force: false },
25538            NodeRequest::Remove { session: address },
25539        ];
25540        for request in &address_requests {
25541            assert!(request_requires_open_provider_ids_with(
25542                request,
25543                |_| Some(agent("third-party-agent")),
25544                |_| Some(agent("claude")),
25545            ));
25546            assert!(!request_requires_open_provider_ids_with(
25547                request,
25548                |_| Some(agent("claude")),
25549                |_| Some(agent("claude")),
25550            ));
25551            assert!(request_requires_open_provider_ids_with(
25552                request,
25553                |_| None,
25554                |_| Some(agent("claude")),
25555            ));
25556        }
25557
25558        let record_requests = [
25559            NodeRequest::RenameSessionRecord {
25560                record_id: record_id.clone(),
25561                display_name: "renamed".to_owned(),
25562            },
25563            NodeRequest::ResumeSessionRecord {
25564                record_id: record_id.clone(),
25565                terminal_size: size,
25566                initial_prompt: None,
25567            },
25568            NodeRequest::ForgetSessionRecord { record_id },
25569        ];
25570        for request in &record_requests {
25571            assert!(request_requires_open_provider_ids_with(
25572                request,
25573                |_| Some(agent("claude")),
25574                |_| Some(agent("grok")),
25575            ));
25576            assert!(!request_requires_open_provider_ids_with(
25577                request,
25578                |_| Some(agent("claude")),
25579                |_| Some(agent("kimi")),
25580            ));
25581            assert!(request_requires_open_provider_ids_with(
25582                request,
25583                |_| Some(agent("claude")),
25584                |_| None,
25585            ));
25586        }
25587
25588        assert!(request_requires_open_provider_ids_with(
25589            &NodeRequest::Spawn {
25590                workspace_id: WorkspaceId::new("primary").unwrap(),
25591                provider: agent("third-party-agent"),
25592                mode: SessionMode::Pty,
25593                terminal_size: size,
25594                initial_prompt: None,
25595            },
25596            |_| None,
25597            |_| None,
25598        ));
25599        assert!(!request_requires_open_provider_ids_with(
25600            &NodeRequest::Snapshot,
25601            |_| None,
25602            |_| None,
25603        ));
25604        assert!(request_requires_open_provider_ids_with(
25605            &NodeRequest::ForgetContextPack {
25606                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25607            },
25608            |_| None,
25609            |_| None,
25610        ));
25611    }
25612
25613    #[test]
25614    fn legacy_projection_filters_open_record_replies_and_event_history() {
25615        let catalog = active_registry().unwrap();
25616        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25617        let workspace = WorkspaceConfig::new(
25618            WorkspaceId::new("primary").unwrap(),
25619            std::env::current_dir().unwrap(),
25620        )
25621        .unwrap();
25622        let shared = NodeShared::new(
25623            handle,
25624            "fixture-token".to_owned(),
25625            NodeId::new("node-1").unwrap(),
25626            vec![workspace],
25627            vec![agent("claude"), agent("grok")],
25628        );
25629        let legacy = record("claude", "legacy-record");
25630        let open = record("grok", "open-record");
25631        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25632            record: legacy.clone(),
25633        });
25634        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25635            record: open.clone(),
25636        });
25637        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25638            record_id: legacy.record_id.clone(),
25639        });
25640        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25641            record_id: open.record_id.clone(),
25642        });
25643        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25644        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25645        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25646        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25647
25648        let mut record_reply = ResponseEnvelope {
25649            request_id: 1,
25650            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25651        };
25652        project_response_legacy_provider_ids(&shared, &mut record_reply);
25653        assert!(matches!(
25654            record_reply.result,
25655            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25656        ));
25657
25658        let mut resync = ResponseEnvelope {
25659            request_id: 2,
25660            result: Ok(NodeResponse::Resync {
25661                event_sequence: open_removed.sequence,
25662                oldest_available_sequence: 1,
25663                snapshot: shared.snapshot(),
25664                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25665            }),
25666        };
25667        project_response_legacy_provider_ids(&shared, &mut resync);
25668        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25669            panic!("legacy projection changed the resync response kind");
25670        };
25671        assert!(snapshot.session_records.iter().all(|item| {
25672            provider_id_is_legacy(&item.provider)
25673        }));
25674        assert_eq!(events.len(), 2);
25675        assert!(events.iter().all(|event| matches!(
25676            &event.event,
25677            NodeEvent::SessionRecordUpserted { record }
25678                if provider_id_is_legacy(&record.provider)
25679        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25680    }
25681
25682    #[test]
25683    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25684        let catalog = active_registry().unwrap();
25685        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25686        let workspace_id = WorkspaceId::new("primary").unwrap();
25687        let workspace = WorkspaceConfig::new(
25688            workspace_id.clone(),
25689            std::env::current_dir().unwrap(),
25690        )
25691        .unwrap();
25692        let shared = NodeShared::new(
25693            handle,
25694            "fixture-token".to_owned(),
25695            NodeId::new("node-1").unwrap(),
25696            vec![workspace],
25697            vec![agent("claude")],
25698        );
25699        for index in 0..CONTROL_SESSIONS_MAX {
25700            shared.bind_session(
25701                AgentInstanceId((index + 1) as u64),
25702                workspace_id.clone(),
25703                SessionGeneration(1),
25704            );
25705        }
25706        let error = shared.ensure_binding_capacity().unwrap_err();
25707        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25708    }
25709
25710    #[tokio::test]
25711    async fn provider_spawn_admission_rejects_before_session_mutation() {
25712        let catalog = active_registry().unwrap();
25713        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25714        let workspace = WorkspaceConfig::new(
25715            WorkspaceId::new("primary").unwrap(),
25716            std::env::current_dir().unwrap(),
25717        )
25718        .unwrap();
25719        let mut shared = NodeShared::new(
25720            handle,
25721            "fixture-token".to_owned(),
25722            NodeId::new("node-provider-admission").unwrap(),
25723            vec![workspace],
25724            vec![agent("claude")],
25725        );
25726        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25727            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25728        ])
25729        .unwrap();
25730        let terminal_size = gate4agent_types::TerminalSize {
25731            rows: 24,
25732            columns: 80,
25733        };
25734
25735        let unavailable = shared
25736            .spawn_session(
25737                WorkspaceId::new("primary").unwrap(),
25738                agent("claude"),
25739                SessionMode::Pty,
25740                terminal_size,
25741                None,
25742            )
25743            .await
25744            .unwrap_err();
25745        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25746        assert_eq!(unavailable.message, "backend-operation-failed");
25747
25748        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25749            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25750                agent("claude"),
25751                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25752            ),
25753        ])
25754        .unwrap();
25755        let semantic = shared
25756            .spawn_session(
25757                WorkspaceId::new("primary").unwrap(),
25758                agent("claude"),
25759                SessionMode::Inline,
25760                terminal_size,
25761                None,
25762            )
25763            .await
25764            .unwrap_err();
25765        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25766        assert_eq!(semantic.message, "unsupported-capability");
25767
25768        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25769        assert!(shared
25770            .session_bindings
25771            .lock()
25772            .unwrap_or_else(|poisoned| poisoned.into_inner())
25773            .is_empty());
25774        assert!(shared
25775            .session_records
25776            .lock()
25777            .unwrap_or_else(|poisoned| poisoned.into_inner())
25778            .records
25779            .is_empty());
25780        assert!(shared.handle.snapshot().sessions.is_empty());
25781    }
25782
25783    #[tokio::test]
25784    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25785        let catalog = active_registry().unwrap();
25786        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25787        let workspace_id = WorkspaceId::new("primary").unwrap();
25788        let workspace = WorkspaceConfig::new(
25789            workspace_id.clone(),
25790            std::env::current_dir().unwrap(),
25791        )
25792        .unwrap();
25793        let shared = NodeShared::new(
25794            handle,
25795            "fixture-token".to_owned(),
25796            NodeId::new("node-raw-spawn-record").unwrap(),
25797            vec![workspace],
25798            vec![agent("claude")],
25799        );
25800        let address = SessionAddress {
25801            workspace_id,
25802            session: SessionKey {
25803                instance_id: AgentInstanceId(71),
25804                generation: SessionGeneration::default(),
25805            },
25806        };
25807
25808        let record_id = shared
25809            .bind_spawn_session(
25810                &address,
25811                agent("claude"),
25812                SessionMode::Pty,
25813                ProviderRuntimePolicy::raw_pty(),
25814                SpawnRecordPolicy::Always,
25815                None,
25816            )
25817            .unwrap()
25818            .unwrap();
25819        shared
25820            .handle
25821            .dispatch(shared.prepare_command(ControlCommand::Register {
25822                instance_id: address.session.instance_id,
25823                agent_id: agent("claude"),
25824                transport: TransportKind::Pty,
25825            }))
25826            .unwrap();
25827        runtime.tick().await;
25828
25829        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25830            session.instance_id == address.session.instance_id
25831                && session.generation == address.session.generation
25832        }));
25833        let record = shared.record(&record_id).unwrap();
25834        assert_eq!(record.display_name, "claude #1");
25835        assert_eq!(record.state, ManagedSessionState::Live);
25836        assert_eq!(record.active_session.as_ref(), Some(&address));
25837        assert!(record.provider_session.is_none());
25838        let binding_record_id = shared
25839            .session_bindings
25840            .lock()
25841            .unwrap_or_else(|poisoned| poisoned.into_inner())
25842            .get(&address.session.instance_id)
25843            .and_then(|binding| binding.record_id.clone());
25844        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25845
25846        let renamed = shared
25847            .rename_session_record(&record_id, "raw review".to_owned())
25848            .unwrap();
25849        assert_eq!(renamed.record_id, record_id);
25850        assert_eq!(renamed.display_name, "raw review");
25851        shared.reconcile_managed_record(
25852            &record_id,
25853            &address,
25854            &ControlEvent {
25855                sequence: 1,
25856                command_id: None,
25857                instance_id: address.session.instance_id,
25858                generation: address.session.generation,
25859                event: ControlEventKind::Running { process_id: None },
25860            },
25861            false,
25862            false,
25863        );
25864        let running = shared.record(&record_id).unwrap();
25865        assert_eq!(running.state, ManagedSessionState::Live);
25866        assert_eq!(running.active_session.as_ref(), Some(&address));
25867        assert_eq!(running.display_name, "raw review");
25868
25869        shared.reconcile_managed_record(
25870            &record_id,
25871            &address,
25872            &ControlEvent {
25873                sequence: 2,
25874                command_id: None,
25875                instance_id: address.session.instance_id,
25876                generation: address.session.generation,
25877                event: ControlEventKind::Exited {
25878                    exit_code: Some(0),
25879                    forced: false,
25880                },
25881            },
25882            false,
25883            false,
25884        );
25885        let detached = shared.record(&record_id).unwrap();
25886        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25887        assert!(detached.active_session.is_none());
25888        assert_eq!(detached.display_name, "raw review");
25889        let renamed_detached = shared
25890            .rename_session_record(&record_id, "raw review detached".to_owned())
25891            .unwrap();
25892        assert_eq!(renamed_detached.record_id, record_id);
25893        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25894        assert!(renamed_detached.active_session.is_none());
25895        assert_eq!(renamed_detached.display_name, "raw review detached");
25896        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25897    }
25898
25899    #[test]
25900    fn legacy_raw_spawn_binding_remains_untracked() {
25901        let catalog = active_registry().unwrap();
25902        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25903        let workspace_id = WorkspaceId::new("primary").unwrap();
25904        let workspace = WorkspaceConfig::new(
25905            workspace_id.clone(),
25906            std::env::current_dir().unwrap(),
25907        )
25908        .unwrap();
25909        let shared = NodeShared::new(
25910            handle,
25911            "fixture-token".to_owned(),
25912            NodeId::new("node-legacy-raw-spawn").unwrap(),
25913            vec![workspace],
25914            vec![agent("claude")],
25915        );
25916        let address = SessionAddress {
25917            workspace_id,
25918            session: SessionKey {
25919                instance_id: AgentInstanceId(72),
25920                generation: SessionGeneration::default(),
25921            },
25922        };
25923
25924        let record_id = shared
25925            .bind_spawn_session(
25926                &address,
25927                agent("claude"),
25928                SessionMode::Pty,
25929                ProviderRuntimePolicy::raw_pty(),
25930                SpawnRecordPolicy::ProviderIdentityOnly,
25931                None,
25932            )
25933            .unwrap();
25934
25935        assert!(record_id.is_none());
25936        let binding_record_id = shared
25937            .session_bindings
25938            .lock()
25939            .unwrap_or_else(|poisoned| poisoned.into_inner())
25940            .get(&address.session.instance_id)
25941            .and_then(|binding| binding.record_id.clone());
25942        assert!(binding_record_id.is_none());
25943        assert!(shared.snapshot().session_records.is_empty());
25944    }
25945
25946    #[tokio::test]
25947    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25948        let catalog = active_registry().unwrap();
25949        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25950        let workspace_id = WorkspaceId::new("primary").unwrap();
25951        let workspace = WorkspaceConfig::new(
25952            workspace_id.clone(),
25953            std::env::current_dir().unwrap(),
25954        )
25955        .unwrap();
25956        let mut shared = NodeShared::new(
25957            handle,
25958            "fixture-token".to_owned(),
25959            NodeId::new("node-raw-semantic-gate").unwrap(),
25960            vec![workspace],
25961            vec![agent("claude")],
25962        );
25963        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25964            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25965        ])
25966        .unwrap();
25967        shared
25968            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25969            .unwrap();
25970        let address = SessionAddress {
25971            workspace_id,
25972            session: SessionKey {
25973                instance_id: AgentInstanceId(72),
25974                generation: SessionGeneration::default(),
25975            },
25976        };
25977        shared.bind_session_with_policy(
25978            &address,
25979            ProviderRuntimePolicy::raw_pty(),
25980            None,
25981        );
25982        shared
25983            .handle
25984            .dispatch(shared.prepare_command(ControlCommand::Register {
25985                instance_id: address.session.instance_id,
25986                agent_id: agent("claude"),
25987                transport: TransportKind::Pty,
25988            }))
25989            .unwrap();
25990        runtime.tick().await;
25991        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25992        let revision_before = shared.handle.snapshot().revision;
25993        let history_sequence_before = shared.current_sequence();
25994        let terminal_size = gate4agent_types::TerminalSize {
25995            rows: 24,
25996            columns: 80,
25997        };
25998
25999        for request in [
26000            NodeRequest::Prompt {
26001                session: address.clone(),
26002                text: "prompt".to_owned(),
26003            },
26004            NodeRequest::Paste {
26005                session: address.clone(),
26006                text: "paste".to_owned(),
26007            },
26008            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
26009            // the same semantic capabilities as `Prompt`/`Paste`. A bare
26010            // (promptless) resume is a raw PTY relaunch and IS admitted under
26011            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
26012            // in `provider_runtime.rs`; it is deliberately not exercised here.
26013            NodeRequest::Resume {
26014                session: address.clone(),
26015                terminal_size,
26016                initial_prompt: Some("resume".to_owned()),
26017            },
26018        ] {
26019            let error = process_request_inner(
26020                &shared,
26021                77,
26022                ClientRole::Operator,
26023                request,
26024            )
26025            .await
26026            .unwrap_err();
26027            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26028        }
26029
26030        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26031        assert_eq!(shared.handle.snapshot().revision, revision_before);
26032        assert_eq!(shared.current_sequence(), history_sequence_before);
26033        let bindings = shared
26034            .session_bindings
26035            .lock()
26036            .unwrap_or_else(|poisoned| poisoned.into_inner());
26037        let binding = bindings.get(&address.session.instance_id).unwrap();
26038        assert_eq!(binding.generation, address.session.generation);
26039        assert!(binding.pending_resume.is_none());
26040        assert!(binding.record_id.is_none());
26041    }
26042
26043    #[tokio::test]
26044    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26045        let catalog = active_registry().unwrap();
26046        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26047        let primary = WorkspaceConfig::new(
26048            WorkspaceId::new("primary").unwrap(),
26049            std::env::current_dir().unwrap(),
26050        )
26051        .unwrap();
26052        let shared = NodeShared::new(
26053            handle,
26054            "fixture-token".to_owned(),
26055            NodeId::new("node-1").unwrap(),
26056            vec![primary],
26057            vec![agent("claude")],
26058        );
26059        shared
26060            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26061            .unwrap();
26062        let before = shared.snapshot();
26063        let error = process_request_inner(
26064            &shared,
26065            77,
26066            ClientRole::Operator,
26067            NodeRequest::RegisterWorkspace {
26068                workspace_id: WorkspaceId::new("foreign").unwrap(),
26069                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26070            },
26071        )
26072        .await
26073        .unwrap_err();
26074        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26075        assert_eq!(error.message, "invalid-request");
26076        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26077    }
26078
26079    #[tokio::test]
26080    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26081        let catalog = active_registry().unwrap();
26082        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26083        let primary_id = WorkspaceId::new("primary").unwrap();
26084        let primary = WorkspaceConfig::new(
26085            primary_id.clone(),
26086            std::env::current_dir().unwrap(),
26087        )
26088        .unwrap();
26089        let shared = NodeShared::new(
26090            handle,
26091            "fixture-token".to_owned(),
26092            NodeId::new("node-1").unwrap(),
26093            vec![primary.clone()],
26094            vec![agent("claude")],
26095        );
26096        let secondary_id = WorkspaceId::new("secondary").unwrap();
26097        let secondary = shared
26098            .register_workspace(
26099                secondary_id.clone(),
26100                std::env::temp_dir().to_string_lossy().into_owned(),
26101            )
26102            .await
26103            .unwrap();
26104        assert_eq!(secondary.workspace_id, secondary_id);
26105        assert!(secondary.sessions.is_empty());
26106        assert_eq!(shared.snapshot().workspaces.len(), 2);
26107
26108        let duplicate_id = shared
26109            .register_workspace(
26110                secondary_id.clone(),
26111                primary.canonical_root().to_owned(),
26112            )
26113            .await
26114            .unwrap_err();
26115        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26116        let duplicate_root = shared
26117            .register_workspace(
26118                WorkspaceId::new("duplicate-root").unwrap(),
26119                windows_path_text(&secondary.canonical_root).to_owned(),
26120            )
26121            .await
26122            .unwrap_err();
26123        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26124
26125        let address = SessionAddress {
26126            workspace_id: secondary_id.clone(),
26127            session: SessionKey {
26128                instance_id: AgentInstanceId(41),
26129                generation: SessionGeneration(1),
26130            },
26131        };
26132        shared.bind_session(
26133            address.session.instance_id,
26134            secondary_id.clone(),
26135            address.session.generation,
26136        );
26137        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26138        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26139        shared.remove_binding(&address);
26140        shared.unregister_workspace(&secondary_id).unwrap();
26141        assert_eq!(shared.snapshot().workspaces.len(), 1);
26142
26143        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26144        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26145        let history = shared
26146            .history
26147            .lock()
26148            .unwrap_or_else(|poisoned| poisoned.into_inner());
26149        assert!(matches!(
26150            &history.events[0],
26151            NodeEventEnvelope {
26152                sequence: 1,
26153                event: NodeEvent::WorkspaceAdded { workspace },
26154            } if workspace.workspace_id == secondary_id
26155        ));
26156        assert!(matches!(
26157            &history.events[1],
26158            NodeEventEnvelope {
26159                sequence: 2,
26160                event: NodeEvent::WorkspaceRemoved { workspace_id },
26161            } if workspace_id == &secondary_id
26162        ));
26163    }
26164
26165    #[tokio::test]
26166    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26167        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26168        std::fs::create_dir_all(&secondary_root).unwrap();
26169        let catalog = active_registry().unwrap();
26170        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26171        let primary_id = WorkspaceId::new("primary").unwrap();
26172        let secondary_id = WorkspaceId::new("secondary").unwrap();
26173        let shared = NodeShared::new(
26174            handle,
26175            "fixture-token".to_owned(),
26176            NodeId::new("node-session-reference-index").unwrap(),
26177            vec![
26178                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26179                    .unwrap(),
26180                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26181            ],
26182            vec![agent("claude"), agent("codex")],
26183        );
26184        let identity = ProviderSessionIdentity {
26185            key: gate4agent_types::ProviderSessionKey::SessionId,
26186            id: "provider-session-42".to_owned(),
26187            transcript_path: None,
26188        };
26189        let index_request = |workspace_id: WorkspaceId,
26190                             provider: AgentId,
26191                             identity: ProviderSessionIdentity,
26192                             display_name: &str| {
26193            NodeRequest::IndexProviderSession {
26194                workspace_id,
26195                provider,
26196                identity,
26197                display_name: display_name.to_owned(),
26198            }
26199        };
26200        let request = index_request(
26201            primary_id.clone(),
26202            agent("claude"),
26203            identity.clone(),
26204            "release shepherd",
26205        );
26206        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26207            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26208        }));
26209        let observer = process_request_inner(
26210            &shared,
26211            77,
26212            ClientRole::Observer,
26213            request.clone(),
26214        )
26215        .await
26216        .unwrap_err();
26217        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26218        let uncontrolled = process_request_inner(
26219            &shared,
26220            77,
26221            ClientRole::Operator,
26222            request.clone(),
26223        )
26224        .await
26225        .unwrap_err();
26226        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26227        shared
26228            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26229            .unwrap();
26230
26231        let indexed = process_request_inner(
26232            &shared,
26233            77,
26234            ClientRole::Operator,
26235            request.clone(),
26236        )
26237        .await
26238        .unwrap();
26239        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26240            panic!("provider session reference index returned another response");
26241        };
26242        assert_eq!(record.state, ManagedSessionState::Dormant);
26243        assert_eq!(record.mode, SessionMode::Pty);
26244        assert_eq!(record.workspace_id, primary_id);
26245        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26246        assert!(record.active_session.is_none());
26247        assert!(record.environment_profile.is_none());
26248        assert!(record.bundle.is_none());
26249        assert!(record.context.is_none());
26250
26251        let repeated = process_request_inner(
26252            &shared,
26253            77,
26254            ClientRole::Operator,
26255            index_request(
26256                primary_id.clone(),
26257                agent("claude"),
26258                identity.clone(),
26259                "ignored replacement",
26260            ),
26261        )
26262        .await
26263        .unwrap();
26264        assert!(matches!(
26265            repeated,
26266            NodeResponse::ProviderSessionIndexed { record: ref existing }
26267                if existing.record_id == record.record_id
26268                    && existing.display_name == "release shepherd"
26269        ));
26270        let conflicting_workspace = process_request_inner(
26271            &shared,
26272            77,
26273            ClientRole::Operator,
26274            index_request(
26275                secondary_id,
26276                agent("claude"),
26277                identity.clone(),
26278                "release shepherd",
26279            ),
26280        )
26281        .await
26282        .unwrap_err();
26283        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26284        let conflicting_provider = process_request_inner(
26285            &shared,
26286            77,
26287            ClientRole::Operator,
26288            index_request(
26289                primary_id.clone(),
26290                agent("codex"),
26291                identity.clone(),
26292                "release shepherd",
26293            ),
26294        )
26295        .await
26296        .unwrap_err();
26297        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26298        let transcript_rejected = process_request_inner(
26299            &shared,
26300            77,
26301            ClientRole::Operator,
26302            index_request(
26303                primary_id,
26304                agent("claude"),
26305                ProviderSessionIdentity {
26306                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26307                    ..identity
26308                },
26309                "release shepherd",
26310            ),
26311        )
26312        .await
26313        .unwrap_err();
26314        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26315        let upserts = shared
26316            .history
26317            .lock()
26318            .unwrap_or_else(|poisoned| poisoned.into_inner())
26319            .events
26320            .iter()
26321            .filter(|event| matches!(
26322                &event.event,
26323                NodeEvent::SessionRecordUpserted { record: published }
26324                    if published.record_id == record.record_id
26325            ))
26326            .count();
26327        assert_eq!(upserts, 1);
26328        std::fs::remove_dir_all(secondary_root).unwrap();
26329    }
26330
26331    #[tokio::test]
26332    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26333        let fixture_root = temporary_workspace_root("standalone-independent");
26334        let target = fixture_root.join("independent");
26335        std::fs::create_dir_all(&fixture_root).unwrap();
26336        let catalog = active_registry().unwrap();
26337        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26338        let selected = WorkspaceConfig::new(
26339            WorkspaceId::new("primary").unwrap(),
26340            std::env::current_dir().unwrap(),
26341        )
26342        .unwrap();
26343        let selected_root = selected.canonical_root().to_owned();
26344        let shared = NodeShared::new(
26345            handle,
26346            "fixture-token".to_owned(),
26347            NodeId::new("node-standalone").unwrap(),
26348            vec![selected],
26349            vec![agent("claude")],
26350        );
26351        let workspace_id = WorkspaceId::new("independent").unwrap();
26352        let request = NodeRequest::CreateStandaloneWorkspace {
26353            workspace_id: workspace_id.clone(),
26354            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26355            initial_branch: Some("main".to_owned()),
26356        };
26357        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26358            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26359        }));
26360        assert!(!request_uses_unnegotiated_capability(
26361            &request,
26362            &baseline_capabilities().unwrap(),
26363        ));
26364        assert!(request_uses_unnegotiated_capability(&request, &[]));
26365
26366        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26367            .await
26368            .unwrap_err();
26369        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26370        assert!(!target.exists());
26371        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26372            .await
26373            .unwrap_err();
26374        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26375        assert!(!target.exists());
26376        shared
26377            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26378            .unwrap();
26379
26380        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26381            .await
26382            .unwrap();
26383        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26384            panic!("standalone workspace creation returned another response");
26385        };
26386        assert_eq!(workspace.workspace_id, workspace_id);
26387        assert!(target.join(".git").is_dir());
26388        let branch = run_git_bounded(
26389            target.to_str().unwrap(),
26390            &["branch", "--show-current"],
26391            GIT_OUTPUT_MAX_BYTES,
26392        )
26393        .await
26394        .unwrap();
26395        assert!(branch.success);
26396        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26397        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26398        assert_eq!(worktrees.len(), 1);
26399        let listed_root = WorkspaceConfig::new(
26400            WorkspaceId::new("listed-independent").unwrap(),
26401            &worktrees[0].path,
26402        )
26403        .unwrap();
26404        assert!(platform::roots_equal(
26405            listed_root.canonical_root(),
26406            windows_path_text(&workspace.canonical_root),
26407        ));
26408        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26409        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26410            snapshot.workspace_id == workspace_id
26411                && snapshot.canonical_root == workspace.canonical_root
26412        }));
26413        assert!(shared
26414            .history
26415            .lock()
26416            .unwrap_or_else(|poisoned| poisoned.into_inner())
26417            .events
26418            .iter()
26419            .any(|event| matches!(
26420                &event.event,
26421                NodeEvent::WorkspaceAdded { workspace }
26422                    if workspace.workspace_id == workspace_id
26423            )));
26424        std::fs::remove_dir_all(fixture_root).unwrap();
26425    }
26426
26427    #[tokio::test]
26428    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26429        let fixture_root = temporary_workspace_root("standalone-compensation");
26430        let created_target = fixture_root.join("created-target");
26431        let existing_target = fixture_root.join("existing-target");
26432        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26433        let invalid_state_path = invalid_state_parent.join("state.json");
26434        std::fs::create_dir_all(&existing_target).unwrap();
26435        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26436        let catalog = active_registry().unwrap();
26437        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26438        let primary = WorkspaceConfig::new(
26439            WorkspaceId::new("primary").unwrap(),
26440            std::env::current_dir().unwrap(),
26441        )
26442        .unwrap();
26443        let mut shared = NodeShared::new(
26444            handle,
26445            "fixture-token".to_owned(),
26446            NodeId::new("node-standalone-compensation").unwrap(),
26447            vec![primary],
26448            vec![agent("claude")],
26449        );
26450        shared.state_path = Some(invalid_state_path);
26451
26452        let created_recovery = shared
26453            .create_standalone_workspace(
26454                WorkspaceId::new("compensated").unwrap(),
26455                created_target.to_string_lossy().into_owned(),
26456                Some("main".to_owned()),
26457            )
26458            .await
26459            .unwrap_err();
26460        assert_eq!(
26461            created_recovery.code,
26462            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26463        );
26464        assert!(created_target.join(".git").is_dir());
26465        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26466            workspace.workspace_id.as_str() != "compensated"
26467        }));
26468
26469        let existing_recovery = shared
26470            .create_standalone_workspace(
26471                WorkspaceId::new("restored").unwrap(),
26472                existing_target.to_string_lossy().into_owned(),
26473                None,
26474            )
26475            .await
26476            .unwrap_err();
26477        assert_eq!(
26478            existing_recovery.code,
26479            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26480        );
26481        assert!(existing_target.is_dir());
26482        assert!(existing_target.join(".git").is_dir());
26483        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26484            workspace.workspace_id.as_str() != "restored"
26485        }));
26486
26487        let registered_empty = fixture_root.join("registered-empty");
26488        std::fs::create_dir(&registered_empty).unwrap();
26489        let catalog = active_registry().unwrap();
26490        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26491        let registered = WorkspaceConfig::new(
26492            WorkspaceId::new("registered-empty").unwrap(),
26493            &registered_empty,
26494        )
26495        .unwrap();
26496        let duplicate_shared = NodeShared::new(
26497            handle,
26498            "fixture-token".to_owned(),
26499            NodeId::new("node-standalone-duplicate-root").unwrap(),
26500            vec![registered],
26501            vec![agent("claude")],
26502        );
26503        let duplicate = duplicate_shared
26504            .create_standalone_workspace(
26505                WorkspaceId::new("duplicate-root").unwrap(),
26506                registered_empty.to_string_lossy().into_owned(),
26507                None,
26508            )
26509            .await
26510            .unwrap_err();
26511        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26512        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26513        std::fs::remove_dir_all(fixture_root).unwrap();
26514    }
26515
26516    #[test]
26517    fn managed_record_id_collision_preserves_the_existing_record() {
26518        let catalog = active_registry().unwrap();
26519        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26520        let workspace = WorkspaceConfig::new(
26521            WorkspaceId::new("primary").unwrap(),
26522            std::env::current_dir().unwrap(),
26523        )
26524        .unwrap();
26525        let shared = NodeShared::new(
26526            handle,
26527            "fixture-token".to_owned(),
26528            NodeId::new("node-1").unwrap(),
26529            vec![workspace.clone()],
26530            vec![agent("claude")],
26531        );
26532        let record_id = SessionRecordId::new("sr-collision").unwrap();
26533        let original = ManagedSessionRecord {
26534            record_id: record_id.clone(),
26535            display_name: "original".to_owned(),
26536            provider: agent("claude"),
26537            mode: SessionMode::Pty,
26538            state: ManagedSessionState::Dormant,
26539            workspace_id: workspace.workspace_id,
26540            canonical_root: opaque_windows_path(workspace.canonical_root),
26541            provider_session: None,
26542            active_session: None,
26543            environment_profile: None,
26544            bundle: None,
26545            context_id: None,
26546            context: None,
26547            exported_context: None,
26548            task_binding: None,
26549            created_at_unix_ms: 1,
26550            updated_at_unix_ms: 1,
26551            last_error: None,
26552        };
26553        shared.insert_record(original.clone()).unwrap();
26554        let mut collision = original.clone();
26555        collision.display_name = "replacement".to_owned();
26556        let error = shared.insert_record(collision).unwrap_err();
26557        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26558        assert_eq!(shared.record(&record_id).unwrap(), original);
26559    }
26560
26561    #[test]
26562    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26563        let catalog = active_registry().unwrap();
26564        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26565        let workspace = WorkspaceConfig::new(
26566            WorkspaceId::new("primary").unwrap(),
26567            std::env::current_dir().unwrap(),
26568        )
26569        .unwrap();
26570        let shared = NodeShared::new(
26571            handle,
26572            "fixture-token".to_owned(),
26573            NodeId::new("node-1").unwrap(),
26574            vec![workspace.clone()],
26575            vec![agent("claude")],
26576        );
26577        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26578        shared
26579            .insert_record(ManagedSessionRecord {
26580                record_id: record_id.clone(),
26581                display_name: "runtime error".to_owned(),
26582                provider: agent("claude"),
26583                mode: SessionMode::Pty,
26584                state: ManagedSessionState::IdentityPending,
26585                workspace_id: workspace.workspace_id,
26586                canonical_root: opaque_windows_path(workspace.canonical_root),
26587                provider_session: None,
26588                active_session: None,
26589                environment_profile: None,
26590                bundle: None,
26591                context_id: None,
26592                context: None,
26593                exported_context: None,
26594                task_binding: None,
26595                created_at_unix_ms: 1,
26596                updated_at_unix_ms: 1,
26597                last_error: None,
26598            })
26599            .unwrap();
26600
26601        shared
26602            .mark_record_error(
26603                &record_id,
26604                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26605            )
26606            .unwrap();
26607        let record = shared.record(&record_id).unwrap();
26608        assert_eq!(record.state, ManagedSessionState::Unavailable);
26609        assert_eq!(
26610            record.last_error.as_deref(),
26611            Some("provider-runtime-failed"),
26612        );
26613        let snapshot = shared.snapshot();
26614        assert_eq!(snapshot.session_records, vec![record]);
26615    }
26616
26617    /// Regression coverage for the "operator sees `deadline` instead of the
26618    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26619    /// able to learn a command's asynchronous kernel rejection (here,
26620    /// specifically classified as `UnsupportedTransport`) exactly once, and
26621    /// must not still find it after that -- proving the fast path this
26622    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26623    /// loop) drains the reason instead of leaving it to be rediscovered (or
26624    /// missed) on a later poll.
26625    #[test]
26626    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26627        let catalog = active_registry().unwrap();
26628        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629        let workspace = WorkspaceConfig::new(
26630            WorkspaceId::new("primary").unwrap(),
26631            std::env::current_dir().unwrap(),
26632        )
26633        .unwrap();
26634        let shared = NodeShared::new(
26635            handle,
26636            "fixture-token".to_owned(),
26637            NodeId::new("node-1").unwrap(),
26638            vec![workspace],
26639            vec![agent("claude")],
26640        );
26641
26642        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26643
26644        shared.record_rejected_command(
26645            CommandId(1),
26646            "agent 'claude' does not support transport Acp".to_owned(),
26647            true,
26648        );
26649        // An unrelated command_id must not see another command's reason.
26650        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26651
26652        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26653        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26654        assert!(reason.unsupported_transport);
26655
26656        // Consumed: a second read for the same command_id finds nothing.
26657        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26658
26659        // A rejection unrelated to transport support keeps its own
26660        // classification distinct, so the reader can choose the generic
26661        // `BackendOperationFailed` code instead of misreporting
26662        // `UnsupportedTransport`.
26663        shared.record_rejected_command(
26664            CommandId(3),
26665            "backend busy".to_owned(),
26666            false,
26667        );
26668        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26669        assert!(!unclassified.unsupported_transport);
26670    }
26671
26672    #[test]
26673    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26674        let catalog = active_registry().unwrap();
26675        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26676        let workspace = WorkspaceConfig::new(
26677            WorkspaceId::new("primary").unwrap(),
26678            std::env::current_dir().unwrap(),
26679        )
26680        .unwrap();
26681        let shared = NodeShared::new(
26682            handle,
26683            "fixture-token".to_owned(),
26684            NodeId::new("node-identity-policy").unwrap(),
26685            vec![workspace.clone()],
26686            vec![agent("claude")],
26687        );
26688        let address = SessionAddress {
26689            workspace_id: workspace.workspace_id.clone(),
26690            session: SessionKey {
26691                instance_id: AgentInstanceId(90),
26692                generation: SessionGeneration(1),
26693            },
26694        };
26695        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26696        shared
26697            .insert_record(ManagedSessionRecord {
26698                record_id: record_id.clone(),
26699                display_name: "policy denied".to_owned(),
26700                provider: agent("claude"),
26701                mode: SessionMode::Pty,
26702                state: ManagedSessionState::IdentityPending,
26703                workspace_id: workspace.workspace_id,
26704                canonical_root: opaque_windows_path(workspace.canonical_root),
26705                provider_session: None,
26706                active_session: Some(address.clone()),
26707                environment_profile: None,
26708                bundle: None,
26709                context_id: None,
26710                context: None,
26711                exported_context: None,
26712                task_binding: None,
26713                created_at_unix_ms: 1,
26714                updated_at_unix_ms: 1,
26715                last_error: None,
26716            })
26717            .unwrap();
26718        let event = ControlEvent {
26719            sequence: 1,
26720            command_id: None,
26721            instance_id: address.session.instance_id,
26722            generation: address.session.generation,
26723            event: ControlEventKind::ProviderEvent {
26724                sequence: 1,
26725                source: gate4agent_types::ProviderSource {
26726                    family: AdapterFamily::PtySemantic,
26727                    binding: AdapterBinding::new(
26728                        gate4agent_types::AdapterId::new("claude").unwrap(),
26729                        "fixture/v1",
26730                        gate4agent_types::AdapterVerification::SyntheticFixture,
26731                    )
26732                    .unwrap(),
26733                },
26734                source_sequence: 1,
26735                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26736                    identity: gate4agent_types::ProviderSessionIdentity {
26737                        key: gate4agent_types::ProviderSessionKey::SessionId,
26738                        id: "provider-session".to_owned(),
26739                        transcript_path: None,
26740                    },
26741                },
26742            },
26743        };
26744
26745        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26746
26747        let record = shared.record(&record_id).unwrap();
26748        assert!(record.provider_session.is_none());
26749        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26750        assert_eq!(record.active_session.as_ref(), Some(&address));
26751    }
26752
26753    #[test]
26754    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26755        let secondary_root = temporary_workspace_root("identity-scope");
26756        std::fs::create_dir_all(&secondary_root).unwrap();
26757        let catalog = active_registry().unwrap();
26758        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26759        let primary = WorkspaceConfig::new(
26760            WorkspaceId::new("primary").unwrap(),
26761            std::env::current_dir().unwrap(),
26762        )
26763        .unwrap();
26764        let secondary = WorkspaceConfig::new(
26765            WorkspaceId::new("secondary").unwrap(),
26766            &secondary_root,
26767        )
26768        .unwrap();
26769        let shared = NodeShared::new(
26770            handle,
26771            "fixture-token".to_owned(),
26772            NodeId::new("node-1").unwrap(),
26773            vec![primary.clone(), secondary.clone()],
26774            vec![agent("claude")],
26775        );
26776        let identity = gate4agent_types::ProviderSessionIdentity {
26777            key: gate4agent_types::ProviderSessionKey::SessionId,
26778            id: "provider-session".to_owned(),
26779            transcript_path: None,
26780        };
26781        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26782        shared
26783            .insert_record(ManagedSessionRecord {
26784                record_id: dormant_id.clone(),
26785                display_name: "dormant".to_owned(),
26786                provider: agent("claude"),
26787                mode: SessionMode::Pty,
26788                state: ManagedSessionState::Dormant,
26789                workspace_id: primary.workspace_id,
26790                canonical_root: opaque_windows_path(primary.canonical_root),
26791                provider_session: Some(identity.clone()),
26792                active_session: None,
26793                environment_profile: None,
26794                bundle: None,
26795                context_id: None,
26796                context: None,
26797                exported_context: None,
26798                task_binding: None,
26799                created_at_unix_ms: 1,
26800                updated_at_unix_ms: 1,
26801                last_error: None,
26802            })
26803            .unwrap();
26804        let address = SessionAddress {
26805            workspace_id: secondary.workspace_id.clone(),
26806            session: SessionKey {
26807                instance_id: AgentInstanceId(91),
26808                generation: SessionGeneration(1),
26809            },
26810        };
26811        let current_id = SessionRecordId::new("sr-current").unwrap();
26812        shared
26813            .insert_record(ManagedSessionRecord {
26814                record_id: current_id.clone(),
26815                display_name: "current".to_owned(),
26816                provider: agent("claude"),
26817                mode: SessionMode::Pty,
26818                state: ManagedSessionState::IdentityPending,
26819                workspace_id: secondary.workspace_id,
26820                canonical_root: opaque_windows_path(secondary.canonical_root),
26821                provider_session: None,
26822                active_session: Some(address.clone()),
26823                environment_profile: None,
26824                bundle: None,
26825                context_id: None,
26826                context: None,
26827                exported_context: None,
26828                task_binding: None,
26829                created_at_unix_ms: 2,
26830                updated_at_unix_ms: 2,
26831                last_error: None,
26832            })
26833            .unwrap();
26834        shared.bind_managed_session(
26835            &address,
26836            current_id.clone(),
26837            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26838            None,
26839        );
26840        let event = ControlEvent {
26841            sequence: 1,
26842            command_id: None,
26843            instance_id: address.session.instance_id,
26844            generation: address.session.generation,
26845            event: ControlEventKind::ProviderEvent {
26846                sequence: 1,
26847                source: gate4agent_types::ProviderSource {
26848                    family: gate4agent_types::AdapterFamily::PtySemantic,
26849                    binding: gate4agent_types::AdapterBinding::new(
26850                        gate4agent_types::AdapterId::new("claude").unwrap(),
26851                        "fixture/v1",
26852                        gate4agent_types::AdapterVerification::SyntheticFixture,
26853                    )
26854                    .unwrap(),
26855                },
26856                source_sequence: 1,
26857                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26858                    identity,
26859                },
26860            },
26861        };
26862        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26863
26864        let binding = shared
26865            .session_bindings
26866            .lock()
26867            .unwrap_or_else(|poisoned| poisoned.into_inner())
26868            .get(&address.session.instance_id)
26869            .cloned()
26870            .unwrap();
26871        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26872        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26873        let current = shared.record(&current_id).unwrap();
26874        assert_eq!(current.state, ManagedSessionState::Unavailable);
26875        assert_eq!(current.active_session.as_ref(), Some(&address));
26876        assert_eq!(
26877            current.last_error.as_deref(),
26878            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26879        );
26880        std::fs::remove_dir_all(secondary_root).unwrap();
26881    }
26882
26883    #[tokio::test]
26884    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26885        let root = temporary_workspace_root("semantic-provider-session");
26886        std::fs::create_dir_all(&root).unwrap();
26887        let state_path = root.join("state-v1.json");
26888        let catalog = active_registry().unwrap();
26889        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26890        let workspace = WorkspaceConfig::new(
26891            WorkspaceId::new("primary").unwrap(),
26892            &root,
26893        )
26894        .unwrap();
26895        let node_id = NodeId::new("node-semantic-identity").unwrap();
26896        let shared = NodeShared::new_with_incarnation(
26897            handle,
26898            "fixture-token".to_owned(),
26899            node_id.clone(),
26900            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26901            vec![workspace.clone()],
26902            vec![agent("claude")],
26903            ProviderRuntimeStatuses::default(),
26904            None,
26905            Vec::new(),
26906            Vec::new(),
26907            SpawnProfileRegistry::default(),
26908            None,
26909            None,
26910            None,
26911            Some(state_path.clone()),
26912            Vec::new(),
26913            Vec::new(),
26914            Vec::new(),
26915            Vec::new(),
26916            None,
26917            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26918        );
26919        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26920            key: gate4agent_types::ProviderSessionKey::SessionId,
26921            id: "provider-session".to_owned(),
26922            transcript_path: None,
26923        };
26924        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26925        shared
26926            .insert_record(ManagedSessionRecord {
26927                record_id: durable_id.clone(),
26928                display_name: "durable".to_owned(),
26929                provider: agent("claude"),
26930                mode: SessionMode::Pty,
26931                state: ManagedSessionState::Dormant,
26932                workspace_id: workspace.workspace_id.clone(),
26933                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26934                provider_session: Some(persisted_identity.clone()),
26935                active_session: None,
26936                environment_profile: None,
26937                bundle: None,
26938                context_id: None,
26939                context: None,
26940                exported_context: None,
26941                task_binding: None,
26942                created_at_unix_ms: 1,
26943                updated_at_unix_ms: 1,
26944                last_error: None,
26945            })
26946            .unwrap();
26947        let address = SessionAddress {
26948            workspace_id: workspace.workspace_id.clone(),
26949            session: SessionKey {
26950                instance_id: AgentInstanceId(92),
26951                generation: SessionGeneration(2),
26952            },
26953        };
26954        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26955        shared
26956            .insert_record(ManagedSessionRecord {
26957                record_id: pending_id.clone(),
26958                display_name: "pending".to_owned(),
26959                provider: agent("claude"),
26960                mode: SessionMode::Pty,
26961                state: ManagedSessionState::IdentityPending,
26962                workspace_id: workspace.workspace_id.clone(),
26963                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26964                provider_session: None,
26965                active_session: Some(address.clone()),
26966                environment_profile: None,
26967                bundle: None,
26968                context_id: None,
26969                context: None,
26970                exported_context: None,
26971                task_binding: None,
26972                created_at_unix_ms: 2,
26973                updated_at_unix_ms: 2,
26974                last_error: None,
26975            })
26976            .unwrap();
26977        shared.bind_managed_session(
26978            &address,
26979            pending_id.clone(),
26980            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26981            None,
26982        );
26983        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26984            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26985            ..persisted_identity
26986        };
26987        let event = ControlEvent {
26988            sequence: 1,
26989            command_id: None,
26990            instance_id: address.session.instance_id,
26991            generation: address.session.generation,
26992            event: ControlEventKind::ProviderEvent {
26993                sequence: 1,
26994                source: gate4agent_types::ProviderSource {
26995                    family: gate4agent_types::AdapterFamily::PtySemantic,
26996                    binding: gate4agent_types::AdapterBinding::new(
26997                        gate4agent_types::AdapterId::new("claude").unwrap(),
26998                        "fixture/v1",
26999                        gate4agent_types::AdapterVerification::SyntheticFixture,
27000                    )
27001                    .unwrap(),
27002                },
27003                source_sequence: 1,
27004                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27005                    identity: observed_identity.clone(),
27006                },
27007            },
27008        };
27009        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27010
27011        let snapshot = shared.snapshot();
27012        assert_eq!(snapshot.session_records.len(), 1);
27013        let live = &snapshot.session_records[0];
27014        assert_eq!(live.record_id, durable_id);
27015        assert_eq!(live.state, ManagedSessionState::Live);
27016        assert_eq!(live.active_session.as_ref(), Some(&address));
27017        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27018        let binding = shared
27019            .session_bindings
27020            .lock()
27021            .unwrap_or_else(|poisoned| poisoned.into_inner())
27022            .get(&address.session.instance_id)
27023            .cloned()
27024            .unwrap();
27025        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27026
27027        let mut duplicate = live.clone();
27028        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27029        duplicate.display_name = "duplicate".to_owned();
27030        duplicate.state = ManagedSessionState::Dormant;
27031        duplicate.active_session = None;
27032        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27033        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27034        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27035        assert_eq!(shared.snapshot().session_records.len(), 1);
27036
27037        let terminal_size = gate4agent_types::TerminalSize {
27038            rows: 24,
27039            columns: 80,
27040        };
27041        let (first_busy, second_busy) = tokio::join!(
27042            shared.resume_session_record(&durable_id, terminal_size, None),
27043            shared.resume_session_record(&durable_id, terminal_size, None),
27044        );
27045        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27046            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27047            assert_eq!(busy.message, "session-record-busy");
27048        }
27049
27050        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27051        assert_eq!(loaded.records.len(), 1);
27052        assert_eq!(loaded.records[0].record_id, durable_id);
27053        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27054        assert_eq!(
27055            loaded.records[0]
27056                .provider_session
27057                .as_ref()
27058                .unwrap()
27059                .transcript_path,
27060            None,
27061        );
27062        let catalog = active_registry().unwrap();
27063        let (reloaded_handle, _reloaded_runtime) =
27064            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27065        let reloaded = NodeShared::new_with_incarnation(
27066            reloaded_handle,
27067            "fixture-token".to_owned(),
27068            node_id,
27069            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27070            vec![workspace],
27071            vec![agent("claude")],
27072            ProviderRuntimeStatuses::default(),
27073            None,
27074            Vec::new(),
27075            Vec::new(),
27076            SpawnProfileRegistry::default(),
27077            None,
27078            None,
27079            None,
27080            None,
27081            loaded.records,
27082            loaded.managed_worktrees,
27083            loaded.managed_worktree_tombstones,
27084            loaded.materializations,
27085            loaded.warning,
27086            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27087        );
27088        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27089        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27090        std::fs::remove_dir_all(root).unwrap();
27091    }
27092
27093    #[tokio::test]
27094    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27095        let secondary_root = temporary_workspace_root("workspace-restore");
27096        let other_root = temporary_workspace_root("workspace-conflict");
27097        std::fs::create_dir_all(&secondary_root).unwrap();
27098        std::fs::create_dir_all(&other_root).unwrap();
27099        let catalog = active_registry().unwrap();
27100        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27101        let primary = WorkspaceConfig::new(
27102            WorkspaceId::new("primary").unwrap(),
27103            std::env::current_dir().unwrap(),
27104        )
27105        .unwrap();
27106        let secondary = WorkspaceConfig::new(
27107            WorkspaceId::new("secondary").unwrap(),
27108            &secondary_root,
27109        )
27110        .unwrap();
27111        let shared = NodeShared::new(
27112            handle,
27113            "fixture-token".to_owned(),
27114            NodeId::new("node-1").unwrap(),
27115            vec![primary, secondary.clone()],
27116            vec![agent("claude")],
27117        );
27118        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27119        shared
27120            .insert_record(ManagedSessionRecord {
27121                record_id: record_id.clone(),
27122                display_name: "workspace record".to_owned(),
27123                provider: agent("claude"),
27124                mode: SessionMode::Pty,
27125                state: ManagedSessionState::Dormant,
27126                workspace_id: secondary.workspace_id.clone(),
27127                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27128                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27129                    key: gate4agent_types::ProviderSessionKey::SessionId,
27130                    id: "provider-session".to_owned(),
27131                    transcript_path: None,
27132                }),
27133                active_session: None,
27134                environment_profile: None,
27135                bundle: None,
27136                context_id: None,
27137                context: None,
27138                exported_context: None,
27139                task_binding: None,
27140                created_at_unix_ms: 1,
27141                updated_at_unix_ms: 1,
27142                last_error: None,
27143            })
27144            .unwrap();
27145        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27146        let unavailable = shared.record(&record_id).unwrap();
27147        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27148        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27149
27150        let conflict = shared
27151            .register_workspace(
27152                secondary.workspace_id.clone(),
27153                other_root.to_string_lossy().into_owned(),
27154            )
27155            .await
27156            .unwrap_err();
27157        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27158        shared
27159            .register_workspace(
27160                secondary.workspace_id,
27161                secondary_root.to_string_lossy().into_owned(),
27162            )
27163            .await
27164            .unwrap();
27165        let restored = shared.record(&record_id).unwrap();
27166        assert_eq!(restored.state, ManagedSessionState::Dormant);
27167        assert!(restored.last_error.is_none());
27168        std::fs::remove_dir_all(secondary_root).unwrap();
27169        std::fs::remove_dir_all(other_root).unwrap();
27170    }
27171
27172    #[test]
27173    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27174        let error = validate_workspace_request_root(
27175            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27176        )
27177        .unwrap_err();
27178        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27179        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27180        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27181    }
27182
27183    #[test]
27184    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27185        let catalog = active_registry().unwrap();
27186        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27187        let workspace = WorkspaceConfig::new(
27188            WorkspaceId::new("primary").unwrap(),
27189            std::env::current_dir().unwrap(),
27190        )
27191        .unwrap();
27192        let shared = NodeShared::new(
27193            handle,
27194            "fixture-token".to_owned(),
27195            NodeId::new("node-1").unwrap(),
27196            vec![workspace],
27197            vec![agent("claude")],
27198        );
27199        let publishers = 4_u64;
27200        let events_per_publisher = 256_u64;
27201        let start = std::sync::Barrier::new(publishers as usize + 1);
27202        let finished = AtomicU64::new(0);
27203
27204        std::thread::scope(|scope| {
27205            for _ in 0..publishers {
27206                scope.spawn(|| {
27207                    start.wait();
27208                    for _ in 0..events_per_publisher {
27209                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27210                    }
27211                    finished.fetch_add(1, Ordering::Release);
27212                });
27213            }
27214            start.wait();
27215            while finished.load(Ordering::Acquire) != publishers {
27216                assert_resync_history_is_linearized(shared.resync(0));
27217                std::thread::yield_now();
27218            }
27219        });
27220        let response = shared.resync(0);
27221        assert_resync_history_is_linearized(response.clone());
27222        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27223            unreachable!("resync helper returned another response");
27224        };
27225        assert_eq!(event_sequence, publishers * events_per_publisher);
27226        assert_eq!(events.len() as u64, event_sequence);
27227    }
27228
27229    fn assert_resync_history_is_linearized(response: NodeResponse) {
27230        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27231            unreachable!("resync helper returned another response");
27232        };
27233        assert!(events.windows(2).all(|pair| {
27234            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27235        }));
27236        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27237    }
27238
27239    #[test]
27240    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27241        assert_eq!(
27242            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27243            r"C:\repo\worktree",
27244        );
27245        assert_eq!(
27246            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27247            r"\\server\share\repo",
27248        );
27249        assert!(validate_workspace_root(
27250            &WorkspaceId::new("network").unwrap(),
27251            r"\\server\share\repo",
27252        )
27253        .is_err());
27254        let workspace = WorkspaceConfig::new(
27255            WorkspaceId::new("primary").unwrap(),
27256            std::env::current_dir().unwrap(),
27257        )
27258        .unwrap();
27259        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27260    }
27261
27262    #[test]
27263    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27264        let catalog = active_registry().unwrap();
27265        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27266        let workspace_id = WorkspaceId::new("primary").unwrap();
27267        let workspace = WorkspaceConfig::new(
27268            workspace_id.clone(),
27269            std::env::current_dir().unwrap(),
27270        )
27271        .unwrap();
27272        let shared = NodeShared::new(
27273            handle,
27274            "fixture-token".to_owned(),
27275            NodeId::new("node-1").unwrap(),
27276            vec![workspace],
27277            vec![agent("claude")],
27278        );
27279        let instance_id = AgentInstanceId(17);
27280        let original = SessionAddress {
27281            workspace_id,
27282            session: SessionKey {
27283                instance_id,
27284                generation: SessionGeneration(3),
27285            },
27286        };
27287        shared.bind_session(
27288            instance_id,
27289            original.workspace_id.clone(),
27290            original.session.generation,
27291        );
27292        shared
27293            .arm_resume(
27294                &original,
27295                CommandId(41),
27296                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27297            )
27298            .unwrap();
27299
27300        shared.publish_control(ControlEvent {
27301            sequence: 1,
27302            command_id: None,
27303            instance_id,
27304            generation: SessionGeneration(4),
27305            event: ControlEventKind::Running { process_id: None },
27306        });
27307        assert_eq!(
27308            shared.address_for(instance_id, SessionGeneration(3)),
27309            Some(original.clone()),
27310        );
27311        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27312
27313        shared.publish_control(ControlEvent {
27314            sequence: 2,
27315            command_id: Some(CommandId(42)),
27316            instance_id,
27317            generation: SessionGeneration(3),
27318            event: ControlEventKind::CommandRejected {
27319                message: "unrelated command".to_owned(),
27320            },
27321        });
27322
27323        shared.publish_control(ControlEvent {
27324            sequence: 3,
27325            command_id: None,
27326            instance_id,
27327            generation: SessionGeneration(4),
27328            event: ControlEventKind::ResumeAuthorized {
27329                session: gate4agent_types::ResumeSessionSummary {
27330                    key: gate4agent_types::ProviderSessionKey::SessionId,
27331                    id: "fixture-session".to_owned(),
27332                },
27333            },
27334        });
27335        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27336        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27337        assert_eq!(rebound.workspace_id, original.workspace_id);
27338        let history = shared
27339            .history
27340            .lock()
27341            .unwrap_or_else(|poisoned| poisoned.into_inner());
27342        assert!(history.events.iter().any(|envelope| matches!(
27343            &envelope.event,
27344            NodeEvent::Control { address, event }
27345                if address == &rebound
27346                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27347        )));
27348    }
27349
27350    #[test]
27351    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27352        for agent in ["claude", "codex", "kimi"] {
27353            assert_eq!(
27354                prompt_framing(&AgentId::new(agent).unwrap()),
27355                PromptFraming::BracketedPaste
27356            );
27357        }
27358    }
27359
27360    fn generous_walk_deadline() -> Instant {
27361        Instant::now() + Duration::from_secs(30)
27362    }
27363
27364    #[test]
27365    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27366        let root = temporary_workspace_root("tree");
27367        std::fs::create_dir_all(root.join("src")).unwrap();
27368        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27369        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27370        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27371        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27372        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27373        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27374        // real repository's own build layout) that carries the Cache
27375        // Directory Tagging Specification marker cargo writes into every
27376        // `--target-dir` it creates: must be skipped even though its name
27377        // does not exactly match `target`.
27378        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27379        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27380        // A directory that merely *starts with* `target` but carries no
27381        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27382        // on the name alone, so this stays in the response.
27383        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27384        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27385
27386        let (entries, truncated, _budget) =
27387            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388        assert!(!truncated);
27389        assert!(entries.iter().any(|entry| {
27390            entry.relative_path.as_utf8() == Some("src")
27391                && entry.kind == WorkspaceEntryKind::Directory
27392        }));
27393        assert!(entries.iter().any(|entry| {
27394            entry.relative_path.as_utf8() == Some("src/lib.rs")
27395                && entry.kind == WorkspaceEntryKind::File
27396        }));
27397        assert!(entries.iter().any(|entry| {
27398            entry.relative_path.as_utf8() == Some("target-notes")
27399                && entry.kind == WorkspaceEntryKind::Directory
27400        }));
27401        assert!(entries.iter().any(|entry| {
27402            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27403                && entry.kind == WorkspaceEntryKind::File
27404        }));
27405        assert!(entries.iter().all(|entry| {
27406            let path = entry.relative_path.as_utf8().unwrap();
27407            !path.starts_with(".git")
27408                && !(path == "target" || path.starts_with("target/"))
27409                && !(path == "target-a2" || path.starts_with("target-a2/"))
27410                && !path.starts_with("node_modules")
27411        }));
27412
27413        std::fs::remove_dir_all(&root).unwrap();
27414    }
27415
27416    #[test]
27417    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27418        let root = temporary_workspace_root("tree-entry-cap");
27419        std::fs::create_dir_all(&root).unwrap();
27420        for index in 0..20 {
27421            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27422        }
27423
27424        let (entries, truncated, budget) =
27425            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27426        assert!(truncated);
27427        assert!(budget.entry_cap_exceeded);
27428        assert!(!budget.time_budget_exceeded);
27429        assert!(entries.len() <= 5);
27430
27431        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27432            .expect("an entry-cap-truncated walk must report a truncation marker");
27433        assert!(inspection_truncation.walk_entry_cap_exceeded);
27434        assert!(!inspection_truncation.walk_time_budget_exceeded);
27435        assert!(!inspection_truncation.git_time_budget_exceeded);
27436        assert!(inspection_truncation.entries_visited >= 5);
27437
27438        std::fs::remove_dir_all(&root).unwrap();
27439    }
27440
27441    #[test]
27442    fn workspace_walk_within_budget_reports_no_truncation() {
27443        let root = temporary_workspace_root("tree-no-truncation");
27444        std::fs::create_dir_all(&root).unwrap();
27445        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27446
27447        let (_entries, truncated, budget) =
27448            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27449        assert!(!truncated);
27450        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27451
27452        std::fs::remove_dir_all(&root).unwrap();
27453    }
27454
27455    #[test]
27456    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27457        let root = temporary_workspace_root("tree-prefix-siblings");
27458        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27459        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27460        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27461        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27462
27463        let (entries, truncated, _budget) =
27464            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27465        assert!(!truncated);
27466        let paths = entries
27467            .iter()
27468            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27469            .collect::<Vec<_>>();
27470        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27471        assert_eq!(
27472            paths,
27473            vec![
27474                "gate4agent-c2".to_owned(),
27475                "gate4agent-c2-client".to_owned(),
27476                "gate4agent-c2-client/lib.rs".to_owned(),
27477                "gate4agent-c2/src".to_owned(),
27478                "gate4agent-c2/src/lib.rs".to_owned(),
27479            ],
27480        );
27481
27482        std::fs::remove_dir_all(&root).unwrap();
27483    }
27484
27485    #[test]
27486    fn git_parsers_cap_status_and_commit_results() {
27487        let mut status_output = Vec::new();
27488        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27489            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27490        }
27491        let mut snapshot = git_snapshot_for_parser();
27492        parse_git_status(&status_output, &mut snapshot);
27493        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27494        assert!(snapshot.truncated);
27495        assert_eq!(snapshot.status[0].index_status, " ");
27496        assert_eq!(snapshot.status[0].worktree_status, "M");
27497
27498        snapshot.truncated = false;
27499        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27500            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27501            .collect::<String>();
27502        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27503        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27504        assert!(snapshot.truncated);
27505    }
27506
27507    #[test]
27508    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27509        let mut snapshot = git_snapshot_for_parser();
27510        parse_git_status(
27511            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27512            &mut snapshot,
27513        );
27514
27515        assert!(!snapshot.truncated);
27516        assert_eq!(snapshot.status.len(), 3);
27517        assert_eq!(snapshot.status[0].index_status, "R");
27518        assert_eq!(snapshot.status[0].worktree_status, " ");
27519        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27520        assert_eq!(
27521            snapshot.status[0]
27522                .previous_path
27523                .as_ref()
27524                .and_then(RepositoryPath::as_utf8),
27525            Some("src/previous name.rs"),
27526        );
27527        assert_eq!(snapshot.status[1].index_status, " ");
27528        assert_eq!(snapshot.status[1].worktree_status, "C");
27529        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27530        assert_eq!(
27531            snapshot.status[1]
27532                .previous_path
27533                .as_ref()
27534                .and_then(RepositoryPath::as_utf8),
27535            Some("src/original.rs"),
27536        );
27537        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27538        assert!(snapshot.status[2].previous_path.is_none());
27539    }
27540
27541    #[test]
27542    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27543        let mut snapshot = git_snapshot_for_parser();
27544        parse_git_status(
27545            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27546            &mut snapshot,
27547        );
27548
27549        assert!(snapshot.truncated);
27550        assert_eq!(snapshot.status.len(), 1);
27551        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27552        assert!(snapshot.status[0].previous_path.is_none());
27553    }
27554
27555    #[tokio::test]
27556    async fn observer_can_browse_host_directories_without_controller() {
27557        let root = temporary_workspace_root("host-directory-observer");
27558        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27559        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27560        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27561        let workspace_id = WorkspaceId::new("primary").unwrap();
27562        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27563        let catalog = active_registry().unwrap();
27564        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27565        let shared = NodeShared::new(
27566            handle,
27567            "fixture-token".to_owned(),
27568            NodeId::new("node-1").unwrap(),
27569            vec![workspace],
27570            vec![agent("claude")],
27571        );
27572        let request = NodeRequest::BrowseHostDirectories {
27573            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27574            after: None,
27575        };
27576        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27577            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27578        }));
27579        assert!(!request_uses_unnegotiated_capability(
27580            &request,
27581            &baseline_capabilities().unwrap(),
27582        ));
27583        assert!(request_uses_unnegotiated_capability(&request, &[]));
27584
27585        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27586            .await
27587            .unwrap();
27588        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27589            panic!("host directory browse returned another response");
27590        };
27591        assert_eq!(
27592            listing
27593                .entries
27594                .iter()
27595                .map(|entry| entry.display_name.as_str())
27596                .collect::<Vec<_>>(),
27597            vec!["directory-a", "directory-b"],
27598        );
27599        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27600        assert!(!listing.incomplete);
27601        std::fs::remove_dir_all(root).unwrap();
27602    }
27603
27604    #[tokio::test]
27605    async fn observer_can_inspect_only_a_registered_workspace() {
27606        let root = temporary_workspace_root("observer");
27607        std::fs::create_dir_all(root.join("src")).unwrap();
27608        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27609        let workspace_id = WorkspaceId::new("primary").unwrap();
27610        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27611        let catalog = active_registry().unwrap();
27612        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27613        let shared = NodeShared::new(
27614            handle,
27615            "fixture-token".to_owned(),
27616            NodeId::new("node-1").unwrap(),
27617            vec![workspace],
27618            vec![agent("claude")],
27619        );
27620
27621        let response = process_request_inner(
27622            &shared,
27623            77,
27624            ClientRole::Observer,
27625            NodeRequest::InspectWorkspace {
27626                workspace_id: workspace_id.clone(),
27627            },
27628        )
27629        .await
27630        .unwrap();
27631        let NodeResponse::WorkspaceInspected { inspection } = response else {
27632            panic!("workspace inspection returned another response");
27633        };
27634        assert_eq!(inspection.workspace_id, workspace_id);
27635        assert!(inspection.entries.iter().any(|entry| {
27636            entry.relative_path.as_utf8() == Some("src/main.rs")
27637        }));
27638
27639        let unknown = process_request_inner(
27640            &shared,
27641            77,
27642            ClientRole::Observer,
27643            NodeRequest::InspectWorkspace {
27644                workspace_id: WorkspaceId::new("unknown").unwrap(),
27645            },
27646        )
27647        .await
27648        .unwrap_err();
27649        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27650        std::fs::remove_dir_all(&root).unwrap();
27651    }
27652
27653    #[test]
27654    fn context_pack_source_accepts_only_running_or_successful_exit() {
27655        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27656        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27657            exit_code: Some(0),
27658        }));
27659        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27660            exit_code: Some(1),
27661        }));
27662        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27663            exit_code: None,
27664        }));
27665        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27666            message: "provider failed".to_owned(),
27667        }));
27668        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27669        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27670        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27671    }
27672
27673    #[test]
27674    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27675        let git = git_snapshot_for_parser();
27676        let first = ContextPackRepositoryObservation {
27677            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27678            git: git.clone(),
27679            files: Vec::new(),
27680        };
27681        let second = ContextPackRepositoryObservation {
27682            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27683            git,
27684            files: Vec::new(),
27685        };
27686
27687        let error = stable_context_pack_repository(first, second).unwrap_err();
27688
27689        assert_eq!(
27690            error.code,
27691            NodeFailureCode::ContextPackMaterializationFailed,
27692        );
27693    }
27694
27695    #[test]
27696    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27697        let git = git_snapshot_for_parser();
27698        let first = ContextPackRepositoryObservation {
27699            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27700            git: git.clone(),
27701            files: vec![ContextPackRepositoryFileSource::utf8(
27702                "README.md",
27703                "first\n".to_owned(),
27704                6,
27705            )],
27706        };
27707        let second = ContextPackRepositoryObservation {
27708            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27709            git,
27710            files: vec![ContextPackRepositoryFileSource::utf8(
27711                "README.md",
27712                "other\n".to_owned(),
27713                6,
27714            )],
27715        };
27716
27717        let error = stable_context_pack_repository(first, second).unwrap_err();
27718
27719        assert_eq!(
27720            error.code,
27721            NodeFailureCode::ContextPackMaterializationFailed,
27722        );
27723    }
27724
27725    #[tokio::test]
27726    async fn native_session_catalog_zero_active_session_fixture() {
27727        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27728        let history_root = temporary_workspace_root("native-catalog-history");
27729        let outside_root = temporary_workspace_root("native-catalog-outside");
27730        std::fs::create_dir_all(&workspace_root).unwrap();
27731        std::fs::create_dir_all(&history_root).unwrap();
27732        std::fs::create_dir_all(&outside_root).unwrap();
27733        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27734            [
27735                serde_json::json!({
27736                    "type": "user",
27737                    "sessionId": session_id,
27738                    "cwd": cwd.to_str().unwrap(),
27739                    "message": { "content": "raw user text must not cross the catalog wire" }
27740                }),
27741                serde_json::json!({
27742                    "type": "assistant",
27743                    "sessionId": session_id,
27744                    "cwd": cwd.to_str().unwrap(),
27745                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27746                }),
27747                serde_json::json!({
27748                    "type": "ai-title",
27749                    "sessionId": session_id,
27750                    "aiTitle": title
27751                }),
27752            ]
27753            .into_iter()
27754            .map(|record| record.to_string())
27755            .collect::<Vec<_>>()
27756            .join("\n")
27757        };
27758        std::fs::write(
27759            history_root.join("stable-session.jsonl"),
27760            transcript("stable-session", &workspace_root, "Workspace review"),
27761        )
27762        .unwrap();
27763        std::fs::write(
27764            history_root.join("outside-session.jsonl"),
27765            transcript("outside-session", &outside_root, "Outside review"),
27766        )
27767        .unwrap();
27768        let history = NativeHistoryConfig::new(vec![
27769            NativeHistoryRoot::new(
27770                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27771                HistorySourceLayout::SingleNdjson,
27772                history_root.clone(),
27773            )
27774            .unwrap(),
27775        ])
27776        .unwrap();
27777        let catalog = active_registry().unwrap();
27778        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27779        assert!(handle.snapshot().sessions.is_empty());
27780        let workspace_id = WorkspaceId::new("primary").unwrap();
27781        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27782        let mut shared = NodeShared::new(
27783            handle,
27784            "fixture-token".to_owned(),
27785            NodeId::new("node-native-catalog").unwrap(),
27786            vec![workspace],
27787            vec![agent("claude")],
27788        );
27789        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27790            NativeSessionCatalogAuthority::new(history),
27791        )));
27792        let route = NativeSessionCatalogRoute::workspace(
27793            workspace_id.clone(),
27794            agent("claude"),
27795        );
27796        let request = NodeRequest::CatalogNativeSessions {
27797            route: route.clone(),
27798            limit: 2,
27799        };
27800        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27801            .await
27802            .unwrap_err();
27803        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27804        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27805            .await
27806            .unwrap();
27807        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27808            panic!("native catalog returned a different response");
27809        };
27810        let summary = summary.expect("node must emit native session catalog summary metadata");
27811        assert_eq!(entries.len(), 1);
27812        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27813        assert_eq!(entries[0].external_group, None);
27814        assert_eq!(entries[0].record_id, None);
27815        assert_eq!(entries[0].title, None);
27816        assert_eq!(entries[0].model, None);
27817        assert_eq!(entries[0].message_count, 0);
27818        let encoded_entries = serde_json::to_string(&entries).unwrap();
27819        assert!(!encoded_entries.contains("stable-session"));
27820        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27821        let page_request = NodeRequest::PageNativeSessions {
27822            route: route.clone(),
27823            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27824            catalog_revision: summary.catalog_revision,
27825            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27826            after_selection_id: None,
27827            limit: 2,
27828        };
27829        let observer = process_request_inner(
27830            &shared,
27831            7,
27832            ClientRole::Observer,
27833            page_request.clone(),
27834        )
27835        .await
27836        .unwrap_err();
27837        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27838        let page_response = process_request_inner(
27839            &shared,
27840            7,
27841            ClientRole::Operator,
27842            page_request.clone(),
27843        )
27844        .await
27845        .unwrap();
27846        assert!(matches!(
27847            page_response,
27848            NodeResponse::NativeSessionsPaged { page, .. }
27849                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27850        ));
27851        let stale = process_request_inner(
27852            &shared,
27853            7,
27854            ClientRole::Operator,
27855            NodeRequest::PageNativeSessions {
27856                route: route.clone(),
27857                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27858                catalog_revision: summary.catalog_revision.wrapping_add(1),
27859                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27860                after_selection_id: None,
27861                limit: 2,
27862            },
27863        )
27864        .await
27865        .unwrap_err();
27866        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27867        let selection = NativeSessionSelection {
27868            route: route.clone(),
27869            catalog_revision: summary.catalog_revision,
27870            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27871            selection_id: entries[0].selection_id.clone(),
27872        };
27873        let preview_response = process_request_inner(
27874            &shared,
27875            7,
27876            ClientRole::Operator,
27877            NodeRequest::PreviewNativeSession {
27878                selection: selection.clone(),
27879                message_limit: 1,
27880            },
27881        )
27882        .await
27883        .unwrap();
27884        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27885            panic!("native preview returned a different response");
27886        };
27887        assert_eq!(preview.messages.len(), 1);
27888        assert!(preview.message_count_exact);
27889        assert_eq!(preview.messages[0].text, "raw answer");
27890        assert!(preview.truncated);
27891
27892        let index_request = NodeRequest::IndexNativeSession {
27893            selection: selection.clone(),
27894            display_name: "Saved Claude".to_owned(),
27895        };
27896        let observer = process_request_inner(
27897            &shared,
27898            7,
27899            ClientRole::Observer,
27900            index_request.clone(),
27901        )
27902        .await
27903        .unwrap_err();
27904        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27905        let without_controller = process_request_inner(
27906            &shared,
27907            7,
27908            ClientRole::Operator,
27909            index_request.clone(),
27910        )
27911        .await
27912        .unwrap_err();
27913        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27914        let acquired = process_request_inner(
27915            &shared,
27916            7,
27917            ClientRole::Operator,
27918            NodeRequest::AcquireController { lease_ms: 5_000 },
27919        )
27920        .await
27921        .unwrap();
27922        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27923        let indexed = process_request_inner(
27924            &shared,
27925            7,
27926            ClientRole::Operator,
27927            index_request,
27928        )
27929        .await
27930        .unwrap();
27931        assert!(indexed.native_session_index_contract_is_valid());
27932        let NodeResponse::NativeSessionIndexed {
27933            selection: echoed_selection,
27934            record,
27935        } = indexed else {
27936            panic!("native session index returned a different response");
27937        };
27938        assert_eq!(echoed_selection, selection);
27939        let recataloged = process_request_inner(
27940            &shared,
27941            7,
27942            ClientRole::Operator,
27943            NodeRequest::CatalogNativeSessions {
27944                route,
27945                limit: 2,
27946            },
27947        )
27948        .await
27949        .unwrap();
27950        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27951            panic!("native catalog returned a different response after indexing");
27952        };
27953        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27954
27955        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27956        let external_catalog = process_request_inner(
27957            &shared,
27958            7,
27959            ClientRole::Operator,
27960            NodeRequest::CatalogNativeSessions {
27961                route: unregistered_route.clone(),
27962                limit: 2,
27963            },
27964        )
27965        .await
27966        .unwrap();
27967        let external_json = serde_json::to_string(&external_catalog).unwrap();
27968        assert!(!external_json.contains("outside-session"));
27969        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27970        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27971        assert!(!external_json.contains("\"session_id\""));
27972        assert!(!external_json.contains("\"cwd\""));
27973        assert!(!external_json.contains("\"path\""));
27974        let NodeResponse::NativeSessionsCataloged {
27975            route: echoed_route,
27976            entries: external_entries,
27977            summary: Some(external_summary),
27978        } = external_catalog
27979        else {
27980            panic!("unregistered native catalog returned a different response");
27981        };
27982        assert_eq!(echoed_route, unregistered_route);
27983        assert_eq!(external_entries.len(), 1);
27984        assert_eq!(external_entries[0].record_id, None);
27985        let external_group = external_entries[0]
27986            .external_group
27987            .as_ref()
27988            .expect("unregistered session must have an external group");
27989        assert_eq!(
27990            external_group.kind,
27991            gate4agent_types::NativeSessionExternalGroupKind::Project,
27992        );
27993        assert!(external_group.group_id.starts_with("external-"));
27994        assert_eq!(
27995            external_group.display_name,
27996            outside_root.file_name().unwrap().to_string_lossy(),
27997        );
27998        let external_selection = NativeSessionSelection {
27999            route: unregistered_route,
28000            catalog_revision: external_summary.catalog_revision,
28001            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28002            selection_id: external_entries[0].selection_id.clone(),
28003        };
28004        let external_preview = process_request_inner(
28005            &shared,
28006            7,
28007            ClientRole::Operator,
28008            NodeRequest::PreviewNativeSession {
28009                selection: external_selection.clone(),
28010                message_limit: 1,
28011            },
28012        )
28013        .await
28014        .unwrap();
28015        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28016        assert!(!external_preview_json.contains("outside-session"));
28017        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28018        assert!(!external_preview_json.contains("\"session_id\""));
28019        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28020            panic!("unregistered native preview returned a different response");
28021        };
28022        assert_eq!(preview.messages.len(), 1);
28023        let external_index = process_request_inner(
28024            &shared,
28025            7,
28026            ClientRole::Operator,
28027            NodeRequest::IndexNativeSession {
28028                selection: external_selection,
28029                display_name: "External Claude".to_owned(),
28030            },
28031        )
28032        .await
28033        .unwrap_err();
28034        assert_eq!(
28035            external_index.code,
28036            NodeFailureCode::WorkspaceRegistrationRequired,
28037        );
28038        let record_response = process_request_inner(
28039            &shared,
28040            7,
28041            ClientRole::Operator,
28042            NodeRequest::PreviewSessionRecord {
28043                record_id: record.record_id.clone(),
28044                message_limit: 2,
28045            },
28046        )
28047        .await
28048        .unwrap();
28049        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28050            panic!("record preview returned a different response");
28051        };
28052        assert_eq!(record_id, record.record_id);
28053        assert_eq!(preview.messages.len(), 2);
28054        let encoded = serde_json::to_string(&preview).unwrap();
28055        assert!(!encoded.contains("stable-session"));
28056        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28057        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28058            panic!("record preview history summary returned a different response");
28059        };
28060        let summaries = events
28061            .iter()
28062            .filter_map(|envelope| match &envelope.event {
28063                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28064                    if record_id == &record.record_id =>
28065                {
28066                    Some(*summary)
28067                }
28068                _ => None,
28069            })
28070            .collect::<Vec<_>>();
28071        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28072        let summary_json = serde_json::to_string(&summaries).unwrap();
28073        for private in [
28074            "stable-session",
28075            "raw user text must not cross the catalog wire",
28076            "raw answer",
28077            "Workspace review",
28078            "claude-sonnet",
28079        ] {
28080            assert!(!summary_json.contains(private), "{private}");
28081        }
28082        let identity = record.provider_session.clone().unwrap();
28083        assert_eq!(
28084            shared.revalidate_session_record_preview(&record, &identity),
28085            Ok(())
28086        );
28087        shared.remove_record_memory(&record.record_id);
28088        assert_eq!(
28089            shared
28090                .revalidate_session_record_preview(&record, &identity)
28091                .unwrap_err()
28092                .code,
28093            NodeFailureCode::UnknownSessionRecord
28094        );
28095        let released = process_request_inner(
28096            &shared,
28097            7,
28098            ClientRole::Operator,
28099            NodeRequest::ReleaseController,
28100        )
28101        .await
28102        .unwrap();
28103        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28104        assert!(shared.controller_state().is_none());
28105        std::fs::remove_dir_all(workspace_root).unwrap();
28106        std::fs::remove_dir_all(history_root).unwrap();
28107        std::fs::remove_dir_all(outside_root).unwrap();
28108    }
28109
28110    #[tokio::test]
28111    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28112        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28113        std::fs::create_dir_all(&history_root).unwrap();
28114        let history = NativeHistoryConfig::new(vec![
28115            NativeHistoryRoot::new(
28116                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28117                HistorySourceLayout::SingleNdjson,
28118                history_root.clone(),
28119            )
28120            .unwrap(),
28121        ])
28122        .unwrap();
28123        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28124        let held = catalog.lock().unwrap();
28125        let result = timeout(
28126            Duration::from_secs(1),
28127            run_native_session_catalog_operation(
28128                Arc::clone(&catalog),
28129                "native session catalog",
28130                |_| Ok::<(), std::convert::Infallible>(()),
28131            ),
28132        )
28133        .await
28134        .expect("concurrent native catalog request must not wait for the operation deadline")
28135        .unwrap_err();
28136        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28137        drop(held);
28138        std::fs::remove_dir_all(history_root).unwrap();
28139    }
28140
28141    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28142        let unique = std::time::SystemTime::now()
28143            .duration_since(std::time::UNIX_EPOCH)
28144            .unwrap()
28145            .as_nanos();
28146        std::env::temp_dir().join(format!(
28147            "gate4agent-node-{label}-{}-{unique}",
28148            std::process::id(),
28149        ))
28150    }
28151
28152    fn git_snapshot_for_parser() -> GitSnapshot {
28153        GitSnapshot {
28154            is_repository: true,
28155            branch: Some("main".to_owned()),
28156            status: Vec::new(),
28157            recent_commits: Vec::new(),
28158            worktrees: Vec::new(),
28159            managed_worktree: None,
28160            truncated: false,
28161            diagnostic: None,
28162        }
28163    }
28164
28165    #[tokio::test]
28166    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28167        let root = temporary_workspace_root("managed-git-scope");
28168        std::fs::create_dir_all(&root).unwrap();
28169        run_test_git(&root, &["init"]);
28170        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28171        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28172        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28173        run_test_git(&root, &["add", "seed.txt"]);
28174        run_test_git(&root, &["commit", "-m", "seed"]);
28175        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28176
28177        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28178        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28179        let catalog = active_registry().unwrap();
28180        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28181        let shared = NodeShared::new(
28182            handle,
28183            "fixture-token".to_owned(),
28184            NodeId::new("node-1").unwrap(),
28185            vec![workspace],
28186            vec![agent("claude")],
28187        );
28188        let mut lease = ManagedWorktreeLeaseRecord {
28189            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28190            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28191            workspace_id: workspace_id.clone(),
28192            profile_id: WorktreeProfileId::new("default").unwrap(),
28193            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28194            target_root: root.to_string_lossy().into_owned(),
28195            branch: "gate4agent/a".to_owned(),
28196            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28197            expected_head: None,
28198            retention: ManagedWorktreeRetention::Retain,
28199            state: ManagedWorktreeLeaseState::InUse,
28200            session_holders: vec![ManagedWorktreeSessionHolder {
28201                incarnation_id: shared.incarnation_id,
28202                instance_id: AgentInstanceId(7),
28203                generation: SessionGeneration(1),
28204            }],
28205            record_holders: Vec::new(),
28206            cleanup_failure: None,
28207            created_at_unix_ms: 1,
28208            updated_at_unix_ms: 1,
28209        };
28210        *shared
28211            .managed_worktrees
28212            .lock()
28213            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28214            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28215
28216        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28217        let scope = inspection.git.managed_worktree.unwrap();
28218        assert_eq!(scope.lease_id, lease.lease_id);
28219        assert_eq!(scope.branch, "gate4agent/a");
28220        assert_eq!(scope.active_session_count, 1);
28221
28222        lease.branch = "gate4agent/b".to_owned();
28223        *shared
28224            .managed_worktrees
28225            .lock()
28226            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28227            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28228        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28229        assert!(mismatch.git.managed_worktree.is_none());
28230        std::fs::remove_dir_all(root).unwrap();
28231    }
28232
28233    #[tokio::test]
28234    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28235        let root = temporary_workspace_root("workspace-file-write-controller");
28236        std::fs::create_dir_all(root.join("src")).unwrap();
28237        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28238        let workspace_id = WorkspaceId::new("primary").unwrap();
28239        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28240        let catalog = active_registry().unwrap();
28241        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28242        let shared = NodeShared::new(
28243            handle,
28244            "fixture-token".to_owned(),
28245            NodeId::new("node-1").unwrap(),
28246            vec![workspace],
28247            vec![agent("claude")],
28248        );
28249        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28250        let expected_revision = workspace_file_revision(b"old\n");
28251        let request = NodeRequest::WriteWorkspaceFile {
28252            workspace_id: workspace_id.clone(),
28253            path: path.clone(),
28254            expected_revision: expected_revision.clone(),
28255            text: "new\n".to_owned(),
28256        };
28257        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28258            .await
28259            .unwrap_err();
28260        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28261        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262            .await
28263            .unwrap_err();
28264        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28265        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28266        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28267        let NodeResponse::WorkspaceFileWritten { file } = response else {
28268            panic!("workspace file write returned another response");
28269        };
28270        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28271        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28272        let stale = process_request_inner(
28273            &shared,
28274            77,
28275            ClientRole::Operator,
28276            NodeRequest::WriteWorkspaceFile {
28277                workspace_id,
28278                path,
28279                expected_revision,
28280                text: "lost update\n".to_owned(),
28281            },
28282        )
28283        .await
28284        .unwrap_err();
28285        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28286        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28287        std::fs::remove_dir_all(root).unwrap();
28288    }
28289
28290    #[tokio::test]
28291    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28292        let root = temporary_workspace_root("workspace-entry-create-controller");
28293        std::fs::create_dir_all(root.join("src")).unwrap();
28294        let workspace_id = WorkspaceId::new("primary").unwrap();
28295        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28296        let catalog = active_registry().unwrap();
28297        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28298        let shared = NodeShared::new(
28299            handle,
28300            "fixture-token".to_owned(),
28301            NodeId::new("node-1").unwrap(),
28302            vec![workspace],
28303            vec![agent("claude")],
28304        );
28305        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28306        let request = NodeRequest::CreateWorkspaceFile {
28307            workspace_id: workspace_id.clone(),
28308            path: path.clone(),
28309        };
28310        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28311            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28312        }));
28313        assert!(!request_uses_unnegotiated_capability(
28314            &request,
28315            &baseline_capabilities().unwrap(),
28316        ));
28317        assert!(request_uses_unnegotiated_capability(&request, &[]));
28318        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28319            .await
28320            .unwrap_err();
28321        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28322        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28323            .await
28324            .unwrap_err();
28325        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28326        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28327
28328        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28329            .await
28330            .unwrap();
28331        let NodeResponse::WorkspaceFileCreated { file } = response else {
28332            panic!("workspace file create returned another response");
28333        };
28334        assert_eq!(file.workspace_id, workspace_id);
28335        assert_eq!(file.path, path);
28336        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28337        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28338
28339        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28340            .await
28341            .unwrap_err();
28342        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28343
28344        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28345        let response = process_request_inner(
28346            &shared,
28347            77,
28348            ClientRole::Operator,
28349            NodeRequest::CreateWorkspaceDirectory {
28350                workspace_id: workspace_id.clone(),
28351                path: directory_path.clone(),
28352            },
28353        )
28354        .await
28355        .unwrap();
28356        assert_eq!(
28357            response,
28358            NodeResponse::WorkspaceDirectoryCreated {
28359                workspace_id: workspace_id.clone(),
28360                entry: WorkspaceEntry {
28361                    relative_path: directory_path,
28362                    kind: WorkspaceEntryKind::Directory,
28363                },
28364            },
28365        );
28366        assert!(root.join("src/nested").is_dir());
28367
28368        let missing_parent = process_request_inner(
28369            &shared,
28370            77,
28371            ClientRole::Operator,
28372            NodeRequest::CreateWorkspaceFile {
28373                workspace_id: workspace_id.clone(),
28374                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28375            },
28376        )
28377        .await
28378        .unwrap_err();
28379        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28380        let non_directory_parent = process_request_inner(
28381            &shared,
28382            77,
28383            ClientRole::Operator,
28384            NodeRequest::CreateWorkspaceFile {
28385                workspace_id,
28386                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28387            },
28388        )
28389        .await
28390        .unwrap_err();
28391        assert_eq!(
28392            non_directory_parent.code,
28393            NodeFailureCode::RepositoryParentNotDirectory,
28394        );
28395        std::fs::remove_dir_all(root).unwrap();
28396    }
28397
28398    #[tokio::test]
28399    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28400        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28401        std::fs::create_dir_all(root.join("src")).unwrap();
28402        #[cfg(windows)]
28403        let root = std::fs::canonicalize(root).unwrap();
28404        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28405        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28406        let barrier = Arc::new(std::sync::Barrier::new(2));
28407        let done = Arc::new(AtomicBool::new(false));
28408        let task_commit_state = Arc::clone(&commit_state);
28409        let task_barrier = Arc::clone(&barrier);
28410        let task_done = Arc::clone(&done);
28411        let task_root = root.clone();
28412        let task = tokio::task::spawn_blocking(move || {
28413            task_barrier.wait();
28414            let result = create_workspace_file_on_disk(
28415                &task_root,
28416                &path,
28417                &task_commit_state,
28418            );
28419            task_done.store(true, Ordering::Release);
28420            result
28421        });
28422
28423        let error = settle_workspace_entry_create(
28424            task,
28425            Arc::clone(&commit_state),
28426            Duration::from_millis(10),
28427            "fixture deadline",
28428            "fixture task failed",
28429        )
28430        .await
28431        .unwrap_err();
28432        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28433        assert_eq!(
28434            commit_state.load(Ordering::Acquire),
28435            WORKSPACE_ENTRY_CREATE_CANCELED,
28436        );
28437
28438        barrier.wait();
28439        timeout(Duration::from_secs(1), async {
28440            while !done.load(Ordering::Acquire) {
28441                tokio::task::yield_now().await;
28442            }
28443        })
28444        .await
28445        .expect("canceled blocking create did not settle");
28446        assert!(!root.join("src/late.rs").exists());
28447        std::fs::remove_dir_all(root).unwrap();
28448    }
28449}
28450
28451#[cfg(test)]
28452mod provisional_spawn_tests {
28453    use super::*;
28454    use std::sync::Arc;
28455
28456    fn shared() -> NodeShared {
28457        let catalog = active_registry().unwrap();
28458        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28459        let workspace = WorkspaceConfig::new(
28460            WorkspaceId::new("primary").unwrap(),
28461            std::env::current_dir().unwrap(),
28462        )
28463        .unwrap()
28464        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28465        NodeShared::new(
28466            handle,
28467            "fixture-token".to_owned(),
28468            NodeId::new("node-provisional-test").unwrap(),
28469            vec![workspace],
28470            vec![AgentId::new("claude").unwrap()],
28471        )
28472    }
28473
28474    /// Provisional draft map: sessionize by instance id only; 50 sequential
28475    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28476    #[test]
28477    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28478        let shared = shared();
28479        let ws = WorkspaceId::new("primary").unwrap();
28480        let mut ids = Vec::with_capacity(50);
28481        for i in 1u64..=50 {
28482            let id = AgentInstanceId(i);
28483            ids.push(id);
28484            let address = SessionAddress {
28485                workspace_id: ws.clone(),
28486                session: SessionKey {
28487                    instance_id: id,
28488                    generation: SessionGeneration(1),
28489                },
28490            };
28491            shared.mark_spawn_provisional(address);
28492        }
28493        assert_eq!(
28494            shared
28495                .provisional_spawns
28496                .lock()
28497                .unwrap_or_else(|p| p.into_inner())
28498                .len(),
28499            50
28500        );
28501        for id in ids.iter().step_by(2) {
28502            shared.sessionize_spawn(*id);
28503        }
28504        assert_eq!(
28505            shared
28506                .provisional_spawns
28507                .lock()
28508                .unwrap_or_else(|p| p.into_inner())
28509                .len(),
28510            25
28511        );
28512        assert!(shared.take_due_provisional_spawns().is_empty());
28513        {
28514            let mut map = shared
28515                .provisional_spawns
28516                .lock()
28517                .unwrap_or_else(|p| p.into_inner());
28518            let past = Instant::now() - Duration::from_secs(91);
28519            for (_, (_, at)) in map.iter_mut() {
28520                *at = past;
28521            }
28522        }
28523        let due = shared.take_due_provisional_spawns();
28524        assert_eq!(due.len(), 25);
28525        let due_ids: std::collections::BTreeSet<_> =
28526            due.iter().map(|a| a.session.instance_id.0).collect();
28527        let expected: std::collections::BTreeSet<_> =
28528            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28529        assert_eq!(due_ids, expected);
28530        assert!(shared
28531            .provisional_spawns
28532            .lock()
28533            .unwrap_or_else(|p| p.into_inner())
28534            .is_empty());
28535    }
28536
28537    /// Twenty parallel provisional marks must each get their own instance key;
28538    /// killing/sessionizing one id must not touch the others.
28539    #[test]
28540    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28541        let shared = Arc::new(shared());
28542        let ws = WorkspaceId::new("primary").unwrap();
28543        let mut handles = Vec::new();
28544        for i in 1u64..=20 {
28545            let shared = Arc::clone(&shared);
28546            let ws = ws.clone();
28547            handles.push(std::thread::spawn(move || {
28548                let id = AgentInstanceId(i);
28549                let address = SessionAddress {
28550                    workspace_id: ws,
28551                    session: SessionKey {
28552                        instance_id: id,
28553                        generation: SessionGeneration(1),
28554                    },
28555                };
28556                shared.mark_spawn_provisional(address);
28557                id
28558            }));
28559        }
28560        let mut ids = Vec::new();
28561        for h in handles {
28562            ids.push(h.join().unwrap());
28563        }
28564        ids.sort_by_key(|id| id.0);
28565        assert_eq!(ids.len(), 20);
28566        ids.dedup();
28567        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28568        assert_eq!(
28569            shared
28570                .provisional_spawns
28571                .lock()
28572                .unwrap_or_else(|p| p.into_inner())
28573                .len(),
28574            20
28575        );
28576        shared.sessionize_spawn(AgentInstanceId(7));
28577        let left: Vec<_> = shared
28578            .provisional_spawns
28579            .lock()
28580            .unwrap_or_else(|p| p.into_inner())
28581            .keys()
28582            .map(|id| id.0)
28583            .collect();
28584        assert!(!left.contains(&7));
28585        assert_eq!(left.len(), 19);
28586    }
28587}
28588
28589
28590#[cfg(unix)]
28591extern "C" {
28592    #[link_name = "geteuid"]
28593    fn libc_geteuid() -> u32;
28594}
28595
28596#[cfg(test)]
28597mod call_home_link_tests {
28598    use super::*;
28599
28600    #[test]
28601    fn call_home_backoff_is_capped_and_monotonic() {
28602        let mut last = Duration::ZERO;
28603        for attempt in 1..20 {
28604            let next = call_home_backoff(attempt);
28605            assert!(next >= last);
28606            assert!(next <= Duration::from_secs(8));
28607            last = next;
28608        }
28609        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28610    }
28611
28612    #[tokio::test]
28613    async fn call_home_socket_gets_keepalive() {
28614        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28615        let addr = listener.local_addr().unwrap();
28616        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28617        let client = client.unwrap();
28618        enable_call_home_keepalive(&client).unwrap();
28619        let sock = socket2::SockRef::from(&client);
28620        assert!(sock.keepalive().unwrap());
28621        #[cfg(any(target_os = "linux", target_os = "macos"))]
28622        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28623    }
28624}
28625
28626/// A provider transcript for this session id exists in a known provider home.
28627fn provider_session_exists(provider: &str, id: &str) -> bool {
28628    if provider != "claude" { return false; }
28629    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28630    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28631    let name = format!("{id}.jsonl");
28632    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())))
28633}
28634
28635/// Append launch args to a prepared overlay (or create an instance overlay for them).
28636fn overlay_with_extra_args(
28637    provider: &AgentId,
28638    mode: SessionMode,
28639    overlay: Option<PreparedNativeLaunchOverlay>,
28640    args: Vec<OsString>,
28641) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28642    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28643    Ok(match (overlay, args) {
28644        (prepared, args) if args.is_empty() => prepared,
28645        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28646        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28647        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28648            provider.clone(),
28649            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28650            Vec::new(),
28651            args,
28652        ).map_err(|_| bad())?)),
28653    })
28654}
28655
28656/// Log a provider-error decision; escalations and limits go to core as session signals.
28657fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28658    use crate::provider_error::Action;
28659    match act {
28660        Action::Retry { attempt, delay } => tracing::info!(target: "provider_retry", instance_id = instance, provider, via, attempt, max = crate::provider_error::MAX_ATTEMPTS, delay_ms = delay.as_millis() as u64, "provider error: retry scheduled"),
28661        Action::Limit { found } => {
28662            tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28663            if via == "acp" {
28664                crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28665            }
28666        }
28667        Action::Escalate { found, attempts } => {
28668            tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28669            crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28670        }
28671    }
28672}
28673
28674/// Turn trace in the node log for ACP sessions (start, tools, permission asks, end, errors):
28675/// names and sizes only, never prompt, tool input or output text.
28676fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28677    let id = address.session.instance_id.0;
28678    let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28679    match e {
28680        ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28681        ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28682        ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28683        ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28684        ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28685        ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28686        ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28687        _ => {}
28688    }
28689}