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        // A process-tree kill (Windows `taskkill /T`, a supervisor stop) ends the children before
3537        // the node: drop the journal entry only if the node is still up a moment later, so a
3538        // node killed with its children still restores them.
3539        if !self.shutdown.load(Ordering::Acquire) {
3540            if let Some(file) = self.session_resume_journal() {
3541                let id = address.session.instance_id.0;
3542                let _ = std::thread::Builder::new().name("resume-forget".into()).spawn(move || {
3543                    std::thread::sleep(std::time::Duration::from_secs(5));
3544                    crate::pty_resume::remove(&file, id);
3545                });
3546            }
3547        }
3548        if let Some(accounts) = self.m4a_accounts.as_ref() {
3549            let instance = address.session.instance_id;
3550            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3551            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3552                accounts.remove_session_file(nick);
3553            }
3554        }
3555        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3556            registry.revoke_session(address);
3557        }
3558        if let Some(control) = self.native_launch_profile_control.as_ref() {
3559            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3560        }
3561    }
3562
3563    fn on_shutdown(&self) {
3564        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3565            .into_values()
3566            .collect();
3567        for socket in leaders {
3568            crate::grok_leader::retire(&socket);
3569        }
3570        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3571            let instances = registry.shutdown();
3572            if let Some(control) = self.native_launch_profile_control.as_ref() {
3573                for instance_id in instances {
3574                    control.clear_native_mcp_server_launch_overlay(instance_id);
3575                }
3576            }
3577        }
3578    }
3579
3580    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3581        if let Some(control) = self.native_launch_profile_control.as_ref() {
3582            control.clear_native_mcp_server_launch_overlay(instance_id);
3583        }
3584    }
3585
3586    fn cleanup_session_materialization(
3587        &self,
3588        address: &SessionAddress,
3589        binding: &SessionBinding,
3590    ) -> Result<(), NodeFailure> {
3591        self.cleanup_session_owned_materialization(address, binding)
3592    }
3593
3594    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3595        // Exclusive dig2browser station profile lease is node-local; clear
3596        // on every session unbind / rollback. Never ImportSession/cookies.
3597        #[cfg(feature = "dig2-station-probe")]
3598        {
3599            crate::dig2_station_lease::release_lease_for_holder(
3600                &self.browser_station_leases,
3601                address.session.instance_id,
3602            );
3603        }
3604        if let Some(control) = self.native_launch_profile_control.as_ref() {
3605            control.clear_native_mcp_server_launch_overlay(
3606                address.session.instance_id,
3607            );
3608        }
3609        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3610            registry.revoke_session(address);
3611        }
3612        if removed
3613            .is_some_and(|binding| {
3614                binding.environment_profile.is_some()
3615                    || binding.bundle.is_some()
3616                    || binding.context.is_some()
3617            })
3618        {
3619            if let Some(control) = self.native_launch_profile_control.as_ref() {
3620                control.clear_native_launch_profile_selection(
3621                    address.session.instance_id,
3622                );
3623                control.clear_native_instance_launch_overlay(
3624                    address.session.instance_id,
3625                );
3626            }
3627        }
3628    }
3629}
3630
3631struct SpawnPrepareRequest<'r> {
3632    instance_id: AgentInstanceId,
3633    address: &'r SessionAddress,
3634    provider: &'r AgentId,
3635    mode: SessionMode,
3636    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3637    bundle: Option<&'r ResolvedBundleReceipt>,
3638    context: Option<&'r ResolvedContextPackReceipt>,
3639    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3640    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3641    approval_level: ApprovalLevel,
3642    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3643    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3644}
3645
3646/// Guards held between "session identity minted" and "launch committed".
3647/// Field order is the drop order and matches the pre-refactor local order
3648/// (instance overlay, environment selection, materialization, harness, station lease).
3649struct PreparedSpawn<'a> {
3650    instance_overlay: Option<NativeInstanceOverlayGuard>,
3651    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3652    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3653    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3654    #[cfg(feature = "dig2-station-probe")]
3655    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3656    materialization_id: Option<MaterializationId>,
3657}
3658
3659impl PreparedSpawn<'_> {
3660    /// Session-owned launch state now belongs to the binding.
3661    fn retain_session_guards(&mut self) {
3662        if let Some(selection) = self.environment_selection.take() {
3663            selection.retain();
3664        }
3665        if let Some(overlay) = self.instance_overlay.take() {
3666            overlay.retain();
3667        }
3668        if let Some(guard) = self.materialization_guard.take() {
3669            guard.retain();
3670        }
3671    }
3672
3673    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3674    fn retain_launch_guards(&mut self) {
3675        if let Some(overlay) = self.harness_mcp_overlay.take() {
3676            overlay.retain();
3677        }
3678        #[cfg(feature = "dig2-station-probe")]
3679        if let Some(guard) = self.browser_station_lease_guard.take() {
3680            guard.retain();
3681        }
3682    }
3683}
3684
3685/// Port: everything product-side that must be prepared before a session is
3686/// bound and started (station lease, harness MCP overlay, materialization,
3687/// environment selection, network overlays, launch overlay).
3688trait SpawnPreparer {
3689    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3690    /// Select the native launch profile for an environment-profile session.
3691    fn select_environment(
3692        &self,
3693        instance_id: AgentInstanceId,
3694        provider: &AgentId,
3695        mode: SessionMode,
3696        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3697    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3698    /// Install a prepared launch overlay for the instance.
3699    fn install_launch_overlay(
3700        &self,
3701        instance_id: AgentInstanceId,
3702        overlay: PreparedNativeLaunchOverlay,
3703    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3704}
3705
3706impl NodeShared {
3707    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3708    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3709    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3710        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3711        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3712        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3713        if let Some(file) = self.session_resume_journal() {
3714            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3715        }
3716        key
3717    }
3718
3719    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3720        let accounts = self.m4a_accounts.as_ref()?;
3721        let nick = self.m4a_nick_of(instance_id)?;
3722        match accounts.session_workdir(root, provider.as_str(), &nick) {
3723            Ok(dir) => dir,
3724            Err(error) => {
3725                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3726                None
3727            }
3728        }
3729    }
3730
3731    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3732        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3733    }
3734
3735    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3736    /// `/api/spawn` returns it).
3737    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3738        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3739        for record in records.records.values_mut() {
3740            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3741                record.display_name = nick.to_owned();
3742            }
3743        }
3744    }
3745
3746    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3747    /// the mailbox listener that wakes it.
3748    fn m4a_session_overlay(
3749        &self,
3750        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3751        m4a_provider: &str,
3752        provider: &AgentId,
3753        instance_id: AgentInstanceId,
3754        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3755        session_key: &str,
3756        mode: SessionMode,
3757    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3758        let session_key = session_key.to_owned();
3759        if mode == SessionMode::Acp {
3760            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3761        }
3762        Ok(                match accounts.provision(m4a_provider, &session_key) {
3763                    Ok(account) => {
3764                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3765                        let mut identity = account.env_mutations();
3766                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3767                        let merged = match environment_overlay {
3768                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3769                                .map(PreparedNativeLaunchOverlay::Instance),
3770                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3771                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3772                            }
3773                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3774                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3775                            }
3776                        }
3777                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3778                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3779                        let mut merged = merged;
3780                        match accounts.write_session_file(&account) {
3781                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3782                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3783                                Ok(args) if !args.is_empty() => {
3784                                    merged = match merged {
3785                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3786                                            .append_extra_args(args)
3787                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3788                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3789                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3790                                            .into_instance_with_extra_args(args)
3791                                            .map(PreparedNativeLaunchOverlay::Instance)
3792                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3793                                    };
3794                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3795                                }
3796                                Ok(_) => {}
3797                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3798                            },
3799                        }
3800                        self.m4a_live
3801                            .lock()
3802                            .unwrap_or_else(|p| p.into_inner())
3803                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3804                        self.name_record_after_nick(instance_id, &account.nick);
3805                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3806                        // Letters to this account wake the session: the node listens on its mailbox.
3807                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3808                        Some(merged)
3809                    }
3810                    Err(error) => {
3811                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3812                        environment_overlay
3813                    }
3814                })
3815    }
3816}
3817
3818impl NodeShared {
3819    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3820    /// handed to the agent in session/new, with the account env on that server only.
3821    fn m4a_acp_mcp_overlay(
3822        &self,
3823        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3824        m4a_provider: &str,
3825        provider: &AgentId,
3826        instance_id: AgentInstanceId,
3827        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3828        session_key: &str,
3829    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3830        let Some(control) = self.native_launch_profile_control.as_ref() else {
3831            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3832            return Ok(environment_overlay);
3833        };
3834        let account = match accounts.provision(m4a_provider, session_key) {
3835            Ok(a) => a,
3836            Err(error) => {
3837                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3838                return Ok(environment_overlay);
3839            }
3840        };
3841        let session_file = match accounts.write_session_file(&account) {
3842            Ok(p) => p,
3843            Err(error) => {
3844                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3845                return Ok(environment_overlay);
3846            }
3847        };
3848        let env_entries: Vec<(OsString, OsString)> = account
3849            .env_mutations()
3850            .into_iter()
3851            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3852            .filter_map(|m| m.value.map(|v| (m.key, v)))
3853            .collect();
3854        let args: Vec<OsString> = vec![
3855            "--session-file".into(),
3856            session_file.into_os_string(),
3857            "mcp".into(),
3858            "--provider".into(),
3859            provider.as_str().into(),
3860        ];
3861        let overlay = NativeMcpServerLaunchOverlay::new(
3862            provider.clone(),
3863            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3864            accounts.bin().as_os_str().to_os_string(),
3865            args,
3866            env_entries,
3867            Vec::new(),
3868        );
3869        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())) {
3870            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3871            Err(error) => {
3872                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3873                return Ok(environment_overlay);
3874            }
3875        }
3876        self.m4a_live
3877            .lock()
3878            .unwrap_or_else(|p| p.into_inner())
3879            .insert(account.nick.clone(), (instance_id, provider.clone()));
3880        self.name_record_after_nick(instance_id, &account.nick);
3881        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3882        Ok(environment_overlay)
3883    }
3884}
3885
3886impl SpawnPreparer for NodeShared {
3887    fn select_environment(
3888        &self,
3889        instance_id: AgentInstanceId,
3890        provider: &AgentId,
3891        mode: SessionMode,
3892        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3893    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3894        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3895    }
3896
3897    fn install_launch_overlay(
3898        &self,
3899        instance_id: AgentInstanceId,
3900        overlay: PreparedNativeLaunchOverlay,
3901    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3902        self.install_prepared_launch_overlay(instance_id, overlay)
3903    }
3904
3905    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3906        let SpawnPrepareRequest {
3907            instance_id,
3908            address,
3909            provider,
3910            mode,
3911            environment_profile,
3912            bundle,
3913            context,
3914            managed_authority,
3915            harness_mcp,
3916            approval_level,
3917            network_allowlist,
3918            browser_profile_id,
3919        } = request;
3920        // Track A follow-on: exclusive node-local BrowserStationLease after
3921        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3922        // never ImportSession / cookies on C2. Linux/feature-off: probe
3923        // already refused or stub; this block is cfg'd out when feature off.
3924        #[cfg(feature = "dig2-station-probe")]
3925        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3926            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3927            match acquire_lease(
3928                &self.browser_station_leases,
3929                profile_id,
3930                instance_id,
3931                unix_time_ms(),
3932            ) {
3933                Ok(guard) => Some(guard),
3934                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3935                    return Err(failure(
3936                        NodeFailureCode::BrowserStationProfileBusy,
3937                        "dig2browser station profile is already leased by another session",
3938                    ));
3939                }
3940                Err(BrowserStationLeaseError::CapacityExceeded) => {
3941                    return Err(failure(
3942                        NodeFailureCode::BrowserStationProfileBusy,
3943                        "dig2browser station profile lease capacity exhausted",
3944                    ));
3945                }
3946                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3947                    return Err(failure(
3948                        NodeFailureCode::BrowserStationProfileBusy,
3949                        "session already holds a dig2browser station profile lease",
3950                    ));
3951                }
3952            }
3953        } else {
3954            None
3955        };
3956        #[cfg(not(feature = "dig2-station-probe"))]
3957        let _ = browser_profile_id;
3958        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3959            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3960            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3961            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3962            // both read the identical overlay resolved below, one as child
3963            // environment, the other translated into one stdio MCP server
3964            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3965            // only, and nothing downstream reads a harness-MCP overlay for
3966            // that transport yet.
3967            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3968            {
3969                return Err(failure(
3970                    NodeFailureCode::BindingMismatch,
3971                    "harness MCP reservation does not match the exact PTY/ACP provider",
3972                ));
3973            }
3974            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3975            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3976                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3977            })?;
3978            // Every name below is the caller's, carried in the reservation's
3979            // launch description: which environment variables receive the
3980            // endpoint, token and helper program, what the MCP server is called,
3981            // which argv it gets and which stale variables are erased.
3982            let launch = prepared.launch();
3983            let mut env_entries = vec![
3984                (
3985                    OsString::from(launch.endpoint_env.as_str()),
3986                    prepared.endpoint().as_os_str().to_os_string(),
3987                ),
3988                (
3989                    OsString::from(launch.token_env.as_str()),
3990                    prepared.token().expose().into(),
3991                ),
3992                (
3993                    OsString::from(launch.program_env.as_str()),
3994                    prepared.helper_program().as_os_str().to_os_string(),
3995                ),
3996            ];
3997            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3998                env_entries.push(trace_entry);
3999            }
4000            if let Some(door) = crate::door::door_listen_addr() {
4001                env_entries.push((
4002                    OsString::from("HATCHERY_NODE_DOOR"),
4003                    OsString::from(door.to_string()),
4004                ));
4005                // Same key shape as HQ browser/draft ids:
4006                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
4007                // slot across node boots when the instance counter resets.
4008                let session_key = format!(
4009                    "pty-{}-{}-{}",
4010                    self.incarnation_id,
4011                    address.workspace_id.as_str(),
4012                    address.session.instance_id.0
4013                );
4014                env_entries.push((
4015                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4016                    OsString::from(session_key),
4017                ));
4018            }
4019            let overlay = NativeMcpServerLaunchOverlay::new(
4020                provider.clone(),
4021                launch.server_name.as_str(),
4022                prepared.helper_program().as_os_str().to_os_string(),
4023                launch.args.iter().map(OsString::from).collect(),
4024                env_entries,
4025                launch.scrub_env.iter().map(OsString::from).collect(),
4026            ).map_err(|_| failure(
4027                NodeFailureCode::HarnessMcpUnavailable,
4028                "harness MCP launch overlay is unavailable",
4029            ))?;
4030            control
4031                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4032                .map_err(|_| failure(
4033                    NodeFailureCode::HarnessMcpUnavailable,
4034                    "harness MCP launch overlay could not be installed",
4035                ))?;
4036            // State-change line: the overlay is installed and will be read
4037            // back by the PTY child's environment or, for ACP,
4038            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4039            // time -- no secrets here, only which instance/provider/mode
4040            // now carries a harness-MCP door.
4041            tracing::info!(
4042                node_id = %self.node_id,
4043                instance_id = ?instance_id,
4044                provider = %provider,
4045                mode = ?mode,
4046                "harness MCP overlay installed for spawn",
4047            );
4048            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4049        } else {
4050            None
4051        };
4052        let prepared_materialization = self.prepare_session_materialization(
4053            &address,
4054            &provider,
4055            mode,
4056            environment_profile,
4057            bundle,
4058            context,
4059            managed_authority.cloned(),
4060        )?;
4061        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4062            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4063            None => (None, None),
4064        };
4065        let materialization_id = materialization_guard
4066            .as_ref()
4067            .and_then(SessionMaterializationGuard::id)
4068            .cloned();
4069        let mut environment_selection = SpawnPreparer::select_environment(self, 
4070            instance_id,
4071            &provider,
4072            mode,
4073            environment_profile,
4074        )?;
4075        // Station network overlays: Codex `-c network_access` and/or Claude
4076        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4077        let provider_home = materialization_guard
4078            .as_ref()
4079            .and_then(SessionMaterializationGuard::provider_home);
4080        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4081            &provider,
4082            mode,
4083            approval_level,
4084            network_allowlist,
4085            provider_home,
4086        )?;
4087        let mut network_overlay_args = network_overlay_args;
4088        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4089        {
4090            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4091            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4092            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4093                homes.extend(std::env::split_paths(&extra));
4094            }
4095            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4096            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4097            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4098        }
4099        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4100            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4101        }
4102        // Session registry: journal the session so it is resumed after a node restart.
4103        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::journalled(provider.as_str(), mode == SessionMode::Acp) {
4104            if let Some(file) = self.session_resume_journal() {
4105                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4106                    instance_id: instance_id.0,
4107                    provider: provider.as_str().to_owned(),
4108                    workspace_id: address.workspace_id.as_str().to_owned(),
4109                    provider_session_id: String::new(),
4110                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4111                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4112                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4113                    cwd: String::new(),
4114                    acp: mode == SessionMode::Acp,
4115                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4116                    cols: 0,
4117                    rows: 0,
4118                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4119                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4120                });
4121                if let Some(dir) = file.parent() {
4122                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4123                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4124                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4125                        provider: Some(provider.as_str().to_owned()),
4126                        ..crate::session_meta::Meta::default()
4127                    });
4128                    crate::session_meta::revive(dir, instance_id.0);
4129                }
4130                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4131            }
4132        }
4133        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4134            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4135        }
4136        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4137            (prepared, args) if args.is_empty() => prepared,
4138            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4139                overlay.append_extra_args(args).map_err(|_| {
4140                    failure(
4141                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4142                        "network allowlist overlay could not merge with instance launch args",
4143                    )
4144                })?;
4145                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4146            }
4147            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4148                Some(PreparedNativeLaunchOverlay::Instance(
4149                    overlay
4150                        .into_instance_with_extra_args(args)
4151                        .map_err(|_| {
4152                            failure(
4153                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154                                "network allowlist overlay could not merge with environment overlay",
4155                            )
4156                        })?,
4157                ))
4158            }
4159            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4160                NativeInstanceLaunchOverlay::new(
4161                    provider.clone(),
4162                    match mode {
4163                        SessionMode::Pty => TransportKind::Pty,
4164                        SessionMode::Acp => TransportKind::Acp,
4165                        SessionMode::Inline => TransportKind::Pipe,
4166                    },
4167                    Vec::new(),
4168                    args,
4169                )
4170                .map_err(|_| {
4171                    failure(
4172                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4173                        "network allowlist overlay is invalid for this transport",
4174                    )
4175                })?,
4176            )),
4177        };
4178        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4179        // overlay or appended to the overlay the spawn already carries.
4180        #[cfg(unix)]
4181        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4182        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4183            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4184        });
4185        #[cfg(unix)]
4186        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4187        // signer handle is for sessions without one (operator shells).
4188        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4189        #[cfg(unix)]
4190        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4191            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4192                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4193                let uid = unsafe { libc_geteuid() };
4194                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4195                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4196                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4197                Some(vec![
4198                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4199                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4200                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4201                ])
4202            }
4203            _ => None,
4204        };
4205        #[cfg(not(unix))]
4206        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4207        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4208            (SessionMode::Pty, _, _, Some(identity)) => {
4209                let merged = match environment_overlay {
4210                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4211                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4212                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4213                    }
4214                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4215                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4216                    }
4217                }
4218                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4219                Some(merged)
4220            }
4221            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4222                let session_key = self.m4a_session_key(instance_id);
4223                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4224            }
4225            (SessionMode::Pty, Some(_), None, _) => {
4226                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4227                environment_overlay
4228            }
4229            _ => environment_overlay,
4230        };
4231        let mut instance_overlay = environment_overlay
4232            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4233            .transpose()?
4234            .flatten();
4235        Ok(PreparedSpawn {
4236            instance_overlay,
4237            environment_selection,
4238            materialization_guard,
4239            harness_mcp_overlay,
4240            #[cfg(feature = "dig2-station-probe")]
4241            browser_station_lease_guard,
4242            materialization_id,
4243        })
4244    }
4245}
4246
4247struct SessionRecords {
4248    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4249}
4250
4251struct SpawnIdempotencyCache {
4252    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4253}
4254
4255struct SpawnIdempotencyEntry {
4256    value: SpawnIdempotencyValue,
4257    expires_at: Instant,
4258}
4259
4260enum SpawnIdempotencyValue {
4261    Standard {
4262        spec: SpawnSpec,
4263        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4264    },
4265    Managed {
4266        request: ManagedWorktreeSpawnRequest,
4267        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4268    },
4269    HarnessMcp,
4270}
4271
4272enum DurableManagedSpawnReplayDecision {
4273    Reserved,
4274    PendingLinked,
4275    Committed(ManagedWorktreeSpawnReceipt),
4276}
4277
4278impl SpawnIdempotencyValue {
4279    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4280        match self {
4281            Self::Standard { result, .. } => result
4282                .as_ref()
4283                .ok()
4284                .and_then(|receipt| receipt.context.as_ref()),
4285            Self::Managed { result, .. } => result
4286                .as_ref()
4287                .ok()
4288                .and_then(|receipt| receipt.spawn.context.as_ref()),
4289            Self::HarnessMcp => None,
4290        }
4291        .is_some_and(|receipt| &receipt.id == context_id)
4292    }
4293}
4294
4295#[derive(Clone, Copy)]
4296struct ControllerLease {
4297    connection_id: u64,
4298    expires_at: Instant,
4299}
4300
4301struct NodeEventHistory {
4302    last_sequence: u64,
4303    replay_floor_sequence: u64,
4304    events: VecDeque<NodeEventEnvelope>,
4305    record_providers: BTreeMap<SessionRecordId, AgentId>,
4306    removed_record_providers: BTreeMap<u64, AgentId>,
4307}
4308
4309impl NodeEventHistory {
4310    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4311        Self {
4312            last_sequence: 0,
4313            replay_floor_sequence: 1,
4314            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4315            record_providers,
4316            removed_record_providers: BTreeMap::new(),
4317        }
4318    }
4319}
4320
4321impl NodeShared {
4322    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4323    /// as a descendant of this session's process (with the session's own environment) and
4324    /// never attaches to an operator's shared leader. Same grok home, same login.
4325    fn session_resume_journal(&self) -> Option<PathBuf> {
4326        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4327    }
4328
4329    /// Node start: resume every journalled session through its provider's resume path,
4330    /// under its old instance number, so HQ shows the same chat.
4331    async fn restore_journalled_sessions(&self) {
4332        let Some(file) = self.session_resume_journal() else { return };
4333        if !crate::pty_resume::enabled() { return; }
4334        for e in crate::pty_resume::load(&file) {
4335            crate::pty_resume::remove(&file, e.instance_id);
4336            // Same m4a session key as before the restart: the session keeps its account and nick.
4337            if !e.session_key.is_empty() {
4338                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4339            }
4340            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4341            if e.acp {
4342                let record_id = {
4343                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4344                    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())
4345                };
4346                let cur = self.next_instance_id.load(Ordering::Acquire);
4347                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4348                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4349                let resumed = match record_id {
4350                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4351                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4352                };
4353                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4354                let r = match resumed {
4355                    Ok(a) => Ok(a),
4356                    Err(err) => {
4357                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4358                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4359                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4360                    }
4361                };
4362                match r {
4363                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4364                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4365                }
4366                continue;
4367            }
4368            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4369                // Nothing said yet in that chat: start it fresh under the same number.
4370                let cur = self.next_instance_id.load(Ordering::Acquire);
4371                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4372                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4373                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4374                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4375                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4376                }
4377                continue;
4378            }
4379            let folder = std::path::Path::new(&e.cwd);
4380            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4381            let name = match (in_session_dir, folder.file_name()) {
4382                (true, Some(n)) => n.to_string_lossy().into_owned(),
4383                _ => format!("{} #{}", e.provider, e.instance_id),
4384            };
4385            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4386            let record = match self.index_provider_session(ws, provider, identity, name) {
4387                Ok(r) => r,
4388                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4389            };
4390            let cur = self.next_instance_id.load(Ordering::Acquire);
4391            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4392            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4393            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4394            match r {
4395                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4396                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4397            }
4398        }
4399    }
4400
4401    fn refresh_session_journal(&self) {
4402        let Some(file) = self.session_resume_journal() else { return };
4403        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4404        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4405        crate::pty_resume::refresh(&file, &homes);
4406        let ids: Vec<(u64, String)> = {
4407            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4408            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4409        };
4410        for (inst, sid) in ids {
4411            crate::pty_resume::set_session_id(&file, inst, &sid);
4412        }
4413    }
4414
4415    fn session_grok_leader_socket(
4416        &self,
4417        provider: &AgentId,
4418        mode: SessionMode,
4419        instance_id: AgentInstanceId,
4420    ) -> Option<std::path::PathBuf> {
4421        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4422            return None;
4423        }
4424        let root = self
4425            .m4a_accounts
4426            .as_ref()
4427            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4428            .unwrap_or_else(std::env::temp_dir);
4429        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4430        if let Some(previous) = self
4431            .grok_leaders
4432            .lock()
4433            .unwrap_or_else(|p| p.into_inner())
4434            .insert(instance_id, socket.clone())
4435        {
4436            crate::grok_leader::retire(&previous);
4437        }
4438        Some(socket)
4439    }
4440
4441    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4442    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4443        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4444        if let Some(socket) = socket {
4445            crate::grok_leader::retire(&socket);
4446        }
4447    }
4448
4449    fn new_with_incarnation(
4450        handle: Gate4AgentHandle,
4451        access_token: String,
4452        node_id: NodeId,
4453        incarnation_id: NodeIncarnationId,
4454        workspaces: Vec<WorkspaceConfig>,
4455        enabled_providers: Vec<AgentId>,
4456        provider_runtime_statuses: ProviderRuntimeStatuses,
4457        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4458        provider_contracts: Vec<ProviderContractSupport>,
4459        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4460        spawn_profiles: SpawnProfileRegistry,
4461        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4462        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4463        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4464        state_path: Option<PathBuf>,
4465        records: Vec<ManagedSessionRecord>,
4466        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4467        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4468        materializations: Vec<MaterializationOwnershipRecord>,
4469        persistence_warning: Option<String>,
4470        input_settle_timeout_ms: u64,
4471    ) -> Self {
4472        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4473        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4474        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4475        let (harness_mcp_proxy_event_tx, _) =
4476            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4477        let record_providers = records
4478            .iter()
4479            .map(|record| (record.record_id.clone(), record.provider.clone()))
4480            .collect();
4481        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4482            .iter()
4483            .map(|workspace| {
4484                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4485            })
4486            .collect();
4487        for lease in &managed_worktrees {
4488            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4489        }
4490        let managed_worktree_profiles = workspaces.iter()
4491            .map(|workspace| (
4492                workspace.workspace_id.clone(),
4493                workspace.managed_worktree_profiles.clone(),
4494            ))
4495            .collect();
4496        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4497            managed_worktrees,
4498            managed_worktree_tombstones,
4499        ).expect("durable managed worktree registry was validated while loading");
4500        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4501        managed_worktrees.reattach_record_holders(
4502            &managed_worktree_record_holders(&records),
4503            unix_time_ms(),
4504        );
4505        Self {
4506            handle,
4507            access_token,
4508            node_id,
4509            incarnation_id,
4510            started_at_unix_ms: std::time::SystemTime::now()
4511                .duration_since(std::time::UNIX_EPOCH)
4512                .unwrap_or_default()
4513                .as_millis()
4514                .min(u64::MAX as u128) as u64,
4515            workspaces: RwLock::new(
4516                workspaces
4517                    .into_iter()
4518                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4519                    .collect(),
4520            ),
4521            worktree_service_modes,
4522            managed_worktree_profiles,
4523            managed_worktrees: Mutex::new(managed_worktrees),
4524            enabled_providers,
4525            provider_runtime_statuses,
4526            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4527            provider_runtime_monitor,
4528            provider_contracts,
4529            provider_adapter_contracts,
4530            spawn_profiles,
4531            environment_profiles: RwLock::new(BTreeMap::new()),
4532            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4533            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4534            #[cfg(feature = "dig2-station-probe")]
4535            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4536            bundle_catalog: RwLock::new(BundleCatalog::default()),
4537            delivery_store: Mutex::new(None),
4538            context_pack_store: Mutex::new(None),
4539            context_catalog: RwLock::new(ContextPackCatalog::default()),
4540            native_launch_profile_control,
4541            harness_mcp_registry: None,
4542            native_session_catalog,
4543            session_environment_materializer,
4544            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4545            m4a_live: Mutex::new(BTreeMap::new()),
4546            m4a_keys: Mutex::new(BTreeMap::new()),
4547            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4548            grok_leaders: Mutex::new(BTreeMap::new()),
4549            #[cfg(unix)]
4550            #[cfg(unix)]
4551            signer_proxy: std::sync::OnceLock::new(),
4552            materializations: Mutex::new(
4553                materializations
4554                    .into_iter()
4555                    .map(|record| (record.id().clone(), record))
4556                    .collect(),
4557            ),
4558            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4559                entries: BTreeMap::new(),
4560            }),
4561            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4562            controller: Mutex::new(None),
4563            provisional_spawns: Mutex::new(BTreeMap::new()),
4564            pending_acp_options: Mutex::new(BTreeMap::new()),
4565            history: Mutex::new(NodeEventHistory::new(record_providers)),
4566            event_tx,
4567            terminal_event_tx,
4568            agent_stream_event_tx,
4569            harness_mcp_proxy_event_tx,
4570            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4571            pty_input_wake: tokio::sync::Notify::new(),
4572            pty_input_marks: Mutex::new(BTreeMap::new()),
4573            provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4574            next_connection_id: AtomicU64::new(1),
4575            next_instance_id: AtomicU64::new(1),
4576            next_command_id: AtomicU64::new(1),
4577            shutdown: AtomicBool::new(false),
4578            shutdown_notify: Notify::new(),
4579            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4580            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4581            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4582            mutation_gate: AsyncMutex::new(()),
4583            session_bindings: Mutex::new(BTreeMap::new()),
4584            session_records: Mutex::new(SessionRecords {
4585                records: records
4586                    .into_iter()
4587                    .map(|record| (record.record_id.clone(), record))
4588                    .collect(),
4589            }),
4590            state_path,
4591            persistence_error: RwLock::new(
4592                persistence_warning
4593                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4594            ),
4595            state_transaction: Mutex::new(()),
4596            input_settle_timeout_ms,
4597            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4598            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4599            shell_efficiency_profile: OnceLock::new(),
4600            native_session_gauge: AtomicUsize::new(0),
4601            connection_loop_iterations: AtomicU64::new(0),
4602            connection_events_sent: AtomicU64::new(0),
4603            rejected_commands_total: AtomicU64::new(0),
4604            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4605            drive_loop_iterations_total: AtomicU64::new(0),
4606            drive_loop_iterations_idle: AtomicU64::new(0),
4607            session_record_retention: SessionRecordRetentionConfig::default(),
4608            retired_records_total: AtomicU64::new(0),
4609            #[cfg(feature = "fixture")]
4610            fixture_semantic_hook_policy: false,
4611            #[cfg(feature = "fixture")]
4612            fixture_spawn_managed_worktree_v2_failure_probe:
4613                SpawnManagedWorktreeV2FailureProbe::default(),
4614        }
4615    }
4616
4617    #[cfg(test)]
4618    fn new(
4619        handle: Gate4AgentHandle,
4620        access_token: String,
4621        node_id: NodeId,
4622        workspaces: Vec<WorkspaceConfig>,
4623        enabled_providers: Vec<AgentId>,
4624    ) -> Self {
4625        Self::new_with_incarnation(
4626            handle,
4627            access_token,
4628            node_id,
4629            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4630            workspaces,
4631            enabled_providers,
4632            ProviderRuntimeStatuses::default(),
4633            None,
4634            Vec::new(),
4635            Vec::new(),
4636            SpawnProfileRegistry::default(),
4637            None,
4638            None,
4639            None,
4640            None,
4641            Vec::new(),
4642            Vec::new(),
4643            Vec::new(),
4644            Vec::new(),
4645            None,
4646            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4647        )
4648    }
4649
4650    fn persistence_error(&self) -> Option<String> {
4651        self.persistence_error
4652            .read()
4653            .unwrap_or_else(|poisoned| poisoned.into_inner())
4654            .clone()
4655    }
4656
4657    async fn admit_provider_runtime(
4658        &self,
4659        provider: &AgentId,
4660        requirement: ProviderRuntimeRequirement,
4661    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4662        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4663            let pipe_transport = monitor.supports_pipe_transport(provider);
4664            let acp_transport = monitor.supports_acp_transport(provider);
4665            let provider = provider.clone();
4666            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4667            let (status, admission) = timeout(
4668                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4669                refresh,
4670            )
4671            .await
4672            .map_err(|_| failure(
4673                NodeFailureCode::BackendBusy,
4674                "provider runtime probe exceeded its bounded deadline",
4675            ))?
4676            .map_err(|_| failure(
4677                NodeFailureCode::BackendOperationFailed,
4678                "provider runtime probe task failed",
4679            ))?;
4680            if let Some(status) = status {
4681                self
4682                    .provider_runtime_status_updates
4683                    .write()
4684                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4685                    .insert(status.provider().clone(), status);
4686            }
4687            admission.and_then(|policy| {
4688                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4689            })
4690        } else {
4691            // No live monitor means no catalog to consult -- there is
4692            // nothing here to confirm this provider declares a Pipe or ACP
4693            // transport, so Inline/Acp are rejected the same conservative
4694            // way an undeclared provider always was, rather than admitted
4695            // on trust.
4696            crate::provider_runtime::admit_status(
4697                &self.provider_runtime_statuses,
4698                provider,
4699                false,
4700                false,
4701                requirement,
4702            )
4703        };
4704        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4705        admission.map_err(|error| match error {
4706            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4707                NodeFailureCode::BackendOperationFailed,
4708                "provider launcher is unavailable",
4709            ),
4710            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4711                NodeFailureCode::UnsupportedCapability,
4712                "provider semantic capability is not verified",
4713            ),
4714            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4715                NodeFailureCode::BackendBusy,
4716                "provider runtime probe is already in progress",
4717            ),
4718        })
4719    }
4720
4721    /// Whether the catalog declares a Pipe transport for `provider` --
4722    /// the fact `require_policy` needs to admit or reject
4723    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4724    /// catalog to consult, so this stays conservative and rejects, matching
4725    /// `admit_provider_runtime`'s own no-monitor branch.
4726    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4727        match self.provider_runtime_monitor.as_deref() {
4728            Some(monitor) => monitor.supports_pipe_transport(provider),
4729            None => false,
4730        }
4731    }
4732
4733    /// Whether the catalog declares an ACP transport for `provider` -- the
4734    /// fact `require_policy` needs to admit or reject
4735    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4736    /// `provider_supports_pipe_transport` exactly, including the
4737    /// conservative no-monitor default.
4738    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4739        match self.provider_runtime_monitor.as_deref() {
4740            Some(monitor) => monitor.supports_acp_transport(provider),
4741            None => false,
4742        }
4743    }
4744
4745    /// Grants hook ingestion to a provider whose catalog entry declares a
4746    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4747    ///
4748    /// `admit_status`'s `RawPassthrough` arm hands back
4749    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4750    /// correct for everything it was written to answer, and wrong for hooks
4751    /// specifically. A hook event is not inferred from terminal behaviour,
4752    /// so it has nothing to do with whether a launcher probe verified this
4753    /// CLI version's PTY contract; the authority is the catalog declaring
4754    /// the adapter plus the ingress authenticating the route. Without this
4755    /// the node installs a provider's hooks, the CLI faithfully calls them,
4756    /// and every event is refused -- which is exactly the state grok was in.
4757    ///
4758    /// Applied to both admission paths so the monitor's own derivation and
4759    /// this coarse fallback cannot disagree about the same provider.
4760    fn admit_declared_hook_semantics(
4761        &self,
4762        provider: &AgentId,
4763        policy: ProviderRuntimePolicy,
4764    ) -> ProviderRuntimePolicy {
4765        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4766            return policy;
4767        }
4768        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4769            contract.provider == *provider && contract.family == AdapterFamily::Hook
4770        });
4771        if !declares_hook {
4772            return policy;
4773        }
4774        ProviderRuntimePolicy::new(
4775            policy.raw_pty_lifecycle,
4776            policy.semantic_readiness,
4777            policy.structured_prompt,
4778            policy.provider_session_identity,
4779            policy.semantic_resume,
4780            true,
4781        )
4782        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4783    }
4784
4785    /// Station network allowlist → provider launch overlay argv.
4786    ///
4787    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4788    ///   `provider_native` under Moderate (PTY or ACP).
4789    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4790    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4791    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4792    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4793    ///   no overlay.
4794    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4795    ///   empty overlay when no `provider_native`; invented native keys already
4796    ///   refuse at load / resolve.
4797    ///
4798    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4799    fn resolve_network_allowlist_overlay_args(
4800        &self,
4801        provider: &AgentId,
4802        mode: SessionMode,
4803        approval_level: ApprovalLevel,
4804        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4805        provider_home: Option<&Path>,
4806    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4807        let Some(allowlist_id) = network_allowlist else {
4808            return Ok(Vec::new());
4809        };
4810        let catalog = self
4811            .network_allowlist_catalog
4812            .read()
4813            .unwrap_or_else(|poisoned| poisoned.into_inner());
4814        let Some(entry) = catalog.get(allowlist_id) else {
4815            return Err(failure(
4816                NodeFailureCode::UnknownNetworkAllowlist,
4817                "spawn network allowlist is unavailable on this node",
4818            ));
4819        };
4820
4821        // Claude: settings-shaped overlay from permits (not provider_native).
4822        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4823            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4824                |message| {
4825                    failure(
4826                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4827                        &message,
4828                    )
4829                },
4830            )?;
4831            if !matches!(mode, SessionMode::Pty) {
4832                return Err(failure(
4833                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4834                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4835                ));
4836            }
4837            let Some(home) = provider_home else {
4838                return Err(failure(
4839                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4841                ));
4842            };
4843            let domains =
4844                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4845            if domains.is_empty() {
4846                return Err(failure(
4847                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4848                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4849                ));
4850            }
4851            let settings_path = home.join(
4852                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4853            );
4854            let json =
4855                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4856            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4857                |_| {
4858                    failure(
4859                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860                        "Claude station network settings sidecar could not be written under ProviderHome",
4861                    )
4862                },
4863            )?;
4864            let overlay =
4865                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4866            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4867        }
4868
4869        let Some(native) = entry.provider_native.as_ref() else {
4870            return Ok(Vec::new());
4871        };
4872        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4873            provider.as_str(),
4874            approval_level,
4875            native,
4876        )
4877        .map_err(|message| {
4878            failure(
4879                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4880                &message,
4881            )
4882        })?;
4883        if overlay.is_empty() {
4884            return Ok(Vec::new());
4885        }
4886        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4887            return Err(failure(
4888                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4889                "Codex network_access -c overlay requires PTY or ACP transport",
4890            ));
4891        }
4892        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4893    }
4894
4895    /// Backward-compatible alias used by Codex-focused unit tests.
4896    fn resolve_codex_network_access_overlay_args(
4897        &self,
4898        provider: &AgentId,
4899        mode: SessionMode,
4900        approval_level: ApprovalLevel,
4901        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4902    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4903        self.resolve_network_allowlist_overlay_args(
4904            provider,
4905            mode,
4906            approval_level,
4907            network_allowlist,
4908            None,
4909        )
4910    }
4911
4912    fn resolve_environment_profile(
4913        &self,
4914        resolved: &ResolvedSpawnSpec,
4915    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4916        // Station network allowlist catalog enforce (plan
4917        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4918        // catalog — unknown ids refuse rather than silent ambient. Runs even
4919        // when no environment_profile_id is present so a bare allowlist still
4920        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4921        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4922            let catalog = self
4923                .network_allowlist_catalog
4924                .read()
4925                .unwrap_or_else(|poisoned| poisoned.into_inner());
4926            let Some(entry) = catalog.get(allowlist_id) else {
4927                return Err(failure(
4928                    NodeFailureCode::UnknownNetworkAllowlist,
4929                    "spawn network allowlist is unavailable on this node",
4930                ));
4931            };
4932            // Track B: refuse provider-native mappings this provider cannot
4933            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4934            if let Some(native) = entry.provider_native.as_ref() {
4935                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4936                    resolved.provider.as_str(),
4937                    resolved.approval_level,
4938                    native,
4939                ) {
4940                    return Err(failure(
4941                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4942                        &message,
4943                    ));
4944                }
4945            }
4946        }
4947        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4948        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4949        // cheap named-pipe path reachability must succeed (Windows
4950        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4951        // NodeFailureCode. Runs even without environment_profile_id (same
4952        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4953        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4954        // acquired later in spawn_session_with_deadline (after instance id).
4955        #[cfg(feature = "dig2-station-probe")]
4956        if resolved.browser_profile_id.is_some() {
4957            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4958            match probe_station_reachable() {
4959                Ok(()) => {}
4960                Err(StationProbeError::UnsupportedPlatform)
4961                | Err(StationProbeError::InvalidPipeSuffix) => {
4962                    return Err(failure(
4963                        NodeFailureCode::BrowserStationProbeUnavailable,
4964                        "dig2browser station probe unavailable on this platform",
4965                    ));
4966                }
4967                Err(StationProbeError::Unreachable) => {
4968                    return Err(failure(
4969                        NodeFailureCode::BrowserStationUnreachable,
4970                        "dig2browser station named-pipe is unreachable",
4971                    ));
4972                }
4973            }
4974        }
4975        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4976            return Ok(None);
4977        };
4978        let profiles = self
4979            .environment_profiles
4980            .read()
4981            .unwrap_or_else(|poisoned| poisoned.into_inner());
4982        let binding = profiles.get(profile_id).ok_or_else(|| {
4983            failure(
4984                NodeFailureCode::UnknownEnvironmentProfile,
4985                "spawn environment profile is unavailable on this node",
4986            )
4987        })?;
4988        if binding.provider != resolved.provider
4989            || binding.native_profile_id(resolved.mode).is_none()
4990        {
4991            return Err(failure(
4992                NodeFailureCode::EnvironmentProfileBindingMismatch,
4993                "spawn environment profile does not match the provider and mode",
4994            ));
4995        }
4996        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4997        // after catalog + optional dig2 probe above. Empty/whitespace ids
4998        // already refuse at Spawn*Id construction.
4999        Ok(Some(ResolvedEnvironmentProfileReceipt {
5000            profile_id: binding.id.clone(),
5001            profile_revision: binding.revision.clone(),
5002            network_allowlist: resolved.network_allowlist.clone(),
5003            browser_profile_id: resolved.browser_profile_id.clone(),
5004        }))
5005    }
5006
5007    fn resolve_bundle(
5008        &self,
5009        resolved: &ResolvedSpawnSpec,
5010        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5011    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5012        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5013            return Ok(None);
5014        };
5015        let catalog = self.bundle_catalog
5016            .read()
5017            .unwrap_or_else(|poisoned| poisoned.into_inner());
5018        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5019                failure(
5020                    NodeFailureCode::UnknownBundle,
5021                    "spawn bundle is unavailable on this node",
5022                )
5023            })?;
5024        self.bundle_layout(
5025            &resolved.provider,
5026            resolved.mode,
5027            environment_profile,
5028            bundle,
5029        )?;
5030        Ok(Some(bundle.receipt()))
5031    }
5032
5033    fn begin_delivery_stage(
5034        &self,
5035        manifest: DeliveryBundleManifestV2,
5036    ) -> Result<NodeResponse, NodeFailure> {
5037        let mut store = self
5038            .delivery_store
5039            .lock()
5040            .unwrap_or_else(|poisoned| poisoned.into_inner());
5041        let store = store.as_mut().ok_or_else(|| {
5042            failure(
5043                NodeFailureCode::DeliveryStageStorageFailed,
5044                "node delivery store is unavailable",
5045            )
5046        })?;
5047        let begun = store.begin(manifest).map_err(delivery_failure)?;
5048        Ok(NodeResponse::DeliveryStageBegun {
5049            stage_id: begun.stage_id,
5050            manifest_digest: begun.manifest_digest,
5051            missing_blobs: begun.missing_blobs,
5052        })
5053    }
5054
5055    fn put_delivery_blob_chunk(
5056        &self,
5057        stage_id: DeliveryStageId,
5058        blob_digest: DeliveryBlobDigestV1,
5059        offset: u64,
5060        chunk_hex: DeliveryBlobChunkHexV1,
5061    ) -> Result<NodeResponse, NodeFailure> {
5062        let mut store = self
5063            .delivery_store
5064            .lock()
5065            .unwrap_or_else(|poisoned| poisoned.into_inner());
5066        let store = store.as_mut().ok_or_else(|| {
5067            failure(
5068                NodeFailureCode::DeliveryStageStorageFailed,
5069                "node delivery store is unavailable",
5070            )
5071        })?;
5072        let accepted = store
5073            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5074            .map_err(delivery_failure)?;
5075        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5076            stage_id: accepted.stage_id,
5077            blob_digest: accepted.blob_digest,
5078            next_offset: accepted.next_offset,
5079        })
5080    }
5081
5082    fn commit_delivery_stage(
5083        &self,
5084        stage_id: DeliveryStageId,
5085    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5086        let mut store = self
5087            .delivery_store
5088            .lock()
5089            .unwrap_or_else(|poisoned| poisoned.into_inner());
5090        let store = store.as_mut().ok_or_else(|| {
5091            failure(
5092                NodeFailureCode::DeliveryStageStorageFailed,
5093                "node delivery store is unavailable",
5094            )
5095        })?;
5096        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5097        let mut catalog = self
5098            .bundle_catalog
5099            .write()
5100            .unwrap_or_else(|poisoned| poisoned.into_inner());
5101        let mut next_catalog = catalog.clone();
5102        next_catalog
5103            .insert_idempotent(prepared.bundle().clone())
5104            .map_err(|_| {
5105                failure(
5106                    NodeFailureCode::DeliveryStageConflict,
5107                    "delivery bundle identity conflicts with installed content",
5108                )
5109            })?;
5110        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5111        *catalog = next_catalog;
5112        Ok(receipt)
5113    }
5114
5115    fn abort_delivery_stage(
5116        &self,
5117        stage_id: &DeliveryStageId,
5118    ) -> Result<(), NodeFailure> {
5119        let mut store = self
5120            .delivery_store
5121            .lock()
5122            .unwrap_or_else(|poisoned| poisoned.into_inner());
5123        let store = store.as_mut().ok_or_else(|| {
5124            failure(
5125                NodeFailureCode::DeliveryStageStorageFailed,
5126                "node delivery store is unavailable",
5127            )
5128        })?;
5129        store.abort(stage_id).map_err(delivery_failure)
5130    }
5131
5132    fn resolve_context(
5133        &self,
5134        resolved: &ResolvedSpawnSpec,
5135    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5136        let Some(context_id) = resolved.context_id.as_ref() else {
5137            return Ok(None);
5138        };
5139        self.context_catalog
5140            .read()
5141            .unwrap_or_else(|poisoned| poisoned.into_inner())
5142            .get(context_id)
5143            .map(|context| context.receipt().clone())
5144            .ok_or_else(|| {
5145                failure(
5146                    NodeFailureCode::UnknownContextPack,
5147                    "spawn context pack is unavailable on this node",
5148                )
5149            })
5150            .map(Some)
5151    }
5152
5153    fn bundle_layout(
5154        &self,
5155        provider: &AgentId,
5156        mode: SessionMode,
5157        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5158        bundle: &NodeBundle,
5159    ) -> Result<BundleProviderLayout, NodeFailure> {
5160        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5161            failure(
5162                NodeFailureCode::BundleBindingMismatch,
5163                "spawn bundle does not match the provider and mode",
5164            )
5165        })?;
5166        if layout != BundleProviderLayout::Codex {
5167            return Ok(layout);
5168        }
5169        let profile = environment_profile.and_then(|receipt| {
5170            let profiles = self
5171                .environment_profiles
5172                .read()
5173                .unwrap_or_else(|poisoned| poisoned.into_inner());
5174            let binding = profiles.get(&receipt.profile_id)?;
5175            if binding.revision != receipt.profile_revision
5176                || &binding.provider != provider
5177                || binding.native_profile_id(mode).is_none()
5178            {
5179                return None;
5180            }
5181            self.environment_materialization_profiles
5182                .read()
5183                .unwrap_or_else(|poisoned| poisoned.into_inner())
5184                .get(&receipt.profile_id)
5185                .cloned()
5186        });
5187        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5188            return Err(failure(
5189                NodeFailureCode::BundleBindingMismatch,
5190                "Codex bundle requires an exact isolated CODEX_HOME profile",
5191            ));
5192        }
5193        Ok(layout)
5194    }
5195
5196    fn select_environment_profile(
5197        &self,
5198        instance_id: AgentInstanceId,
5199        provider: &AgentId,
5200        mode: SessionMode,
5201        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5202    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5203        let Some(environment_profile) = environment_profile else {
5204            return Ok(None);
5205        };
5206        let native_profile_id = {
5207            let profiles = self
5208                .environment_profiles
5209                .read()
5210                .unwrap_or_else(|poisoned| poisoned.into_inner());
5211            let binding = profiles
5212                .get(&environment_profile.profile_id)
5213                .ok_or_else(|| {
5214                    failure(
5215                        NodeFailureCode::UnknownEnvironmentProfile,
5216                        "session environment profile is unavailable on this node",
5217                    )
5218                })?;
5219            if &binding.revision != &environment_profile.profile_revision
5220                || &binding.provider != provider
5221            {
5222                return Err(failure(
5223                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5224                    "session environment profile revision or provider changed",
5225                ));
5226            }
5227            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5228                failure(
5229                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5230                    "session environment profile does not support this mode",
5231                )
5232            })?
5233        };
5234        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5235            failure(
5236                NodeFailureCode::BackendOperationFailed,
5237                "native environment profile controller is unavailable",
5238            )
5239        })?;
5240        control
5241            .select_native_launch_profile(instance_id, native_profile_id)
5242            .map_err(|_| {
5243                failure(
5244                    NodeFailureCode::BackendOperationFailed,
5245                    "native environment profile selection failed",
5246                )
5247            })?;
5248        Ok(Some(NativeEnvironmentSelectionGuard {
5249            control: Some(control),
5250            instance_id,
5251        }))
5252    }
5253
5254    fn install_prepared_launch_overlay(
5255        &self,
5256        instance_id: AgentInstanceId,
5257        overlay: PreparedNativeLaunchOverlay,
5258    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5259        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5260            failure(
5261                NodeFailureCode::BackendOperationFailed,
5262                "native launch profile controller is unavailable",
5263            )
5264        })?;
5265        match overlay {
5266            PreparedNativeLaunchOverlay::Environment(overlay) => {
5267                control
5268                    .install_native_launch_environment_overlay(instance_id, overlay)
5269                    .map_err(|_| failure(
5270                        NodeFailureCode::BackendOperationFailed,
5271                        "session environment overlay installation failed",
5272                    ))?;
5273                Ok(None)
5274            }
5275            PreparedNativeLaunchOverlay::Instance(overlay) => {
5276                control
5277                    .install_native_instance_launch_overlay(instance_id, overlay)
5278                    .map_err(|_| failure(
5279                        NodeFailureCode::BundleBindingMismatch,
5280                        "session bundle launch overlay installation failed",
5281                    ))?;
5282                Ok(Some(NativeInstanceOverlayGuard {
5283                    control: Some(control),
5284                    instance_id,
5285                }))
5286            }
5287        }
5288    }
5289
5290    fn environment_materialization_profile(
5291        &self,
5292        environment_profile: &ResolvedEnvironmentProfileReceipt,
5293    ) -> Option<NodeSessionMaterializationProfile> {
5294        self.environment_materialization_profiles
5295            .read()
5296            .unwrap_or_else(|poisoned| poisoned.into_inner())
5297            .get(&environment_profile.profile_id)
5298            .cloned()
5299    }
5300
5301    fn materialization_profile(
5302        &self,
5303        provider: &AgentId,
5304        mode: SessionMode,
5305        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5306        bundle: Option<&ResolvedBundleReceipt>,
5307        context: Option<&ResolvedContextPackReceipt>,
5308    ) -> Result<Option<(
5309        NodeSessionMaterializationProfile,
5310        bool,
5311        Option<BundleProviderLayout>,
5312    )>, NodeFailure> {
5313        let environment = environment_profile
5314            .and_then(|receipt| self.environment_materialization_profile(receipt));
5315        let (bundle, bundle_layout) = match bundle {
5316            Some(receipt) => {
5317                let catalog = self.bundle_catalog.read()
5318                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5319                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5320                    NodeFailureCode::UnknownBundle,
5321                    "session bundle is unavailable on this node",
5322                ))?;
5323                if installed.receipt() != *receipt {
5324                    return Err(failure(
5325                        NodeFailureCode::BundleBindingMismatch,
5326                        "session bundle revision or digest changed",
5327                    ));
5328                }
5329                let layout = self.bundle_layout(
5330                    provider,
5331                    mode,
5332                    environment_profile,
5333                    installed,
5334                )?;
5335                (Some(installed.clone()), Some(layout))
5336            }
5337            None => (None, None),
5338        };
5339        let has_environment_overlay = environment.is_some() || context.is_some();
5340        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5341            (None, None, None) => None,
5342            (Some(profile), None, None) => Some(Ok(profile)),
5343            (None, Some(bundle), Some(layout)) => {
5344                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5345            }
5346            (Some(profile), Some(bundle), Some(layout)) => {
5347                Some(profile.with_bundle(bundle, layout))
5348            }
5349            _ => {
5350                return Err(failure(
5351                    NodeFailureCode::BundleBindingMismatch,
5352                    "session bundle provider layout is unavailable",
5353                ));
5354            }
5355        };
5356        let profile = profile
5357            .transpose()
5358            .map_err(|_| failure(
5359                NodeFailureCode::BundleMaterializationFailed,
5360                "session bundle materialization profile is invalid",
5361            ))?;
5362        let profile = match (profile, context) {
5363            (Some(profile), Some(receipt)) => {
5364                let catalog = self.context_catalog
5365                    .read()
5366                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5367                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5368                    NodeFailureCode::UnknownContextPack,
5369                    "session context pack is unavailable on this node",
5370                ))?;
5371                if pack.receipt() != receipt {
5372                    return Err(failure(
5373                        NodeFailureCode::ContextPackMaterializationFailed,
5374                        "session context pack identity changed",
5375                    ));
5376                }
5377                profile.with_context(pack)
5378            }
5379            (None, Some(receipt)) => {
5380                let catalog = self.context_catalog
5381                    .read()
5382                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5383                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5384                    NodeFailureCode::UnknownContextPack,
5385                    "session context pack is unavailable on this node",
5386                ))?;
5387                if pack.receipt() != receipt {
5388                    return Err(failure(
5389                        NodeFailureCode::ContextPackMaterializationFailed,
5390                        "session context pack identity changed",
5391                    ));
5392                }
5393                NodeSessionMaterializationProfile::from_context(pack)
5394            }
5395            (Some(profile), None) => Ok(profile),
5396            (None, None) => return Ok(None),
5397        }
5398        .map_err(|_| failure(
5399            NodeFailureCode::ContextPackMaterializationFailed,
5400            "session context pack materialization profile is invalid",
5401        ))?;
5402        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5403    }
5404
5405    fn prepare_session_materialization<'a>(
5406        &'a self,
5407        address: &SessionAddress,
5408        provider: &AgentId,
5409        mode: SessionMode,
5410        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5411        bundle: Option<&ResolvedBundleReceipt>,
5412        context: Option<&ResolvedContextPackReceipt>,
5413        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5414    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5415        let Some((profile, has_environment_overlay, bundle_layout)) =
5416            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5417        else {
5418            return Ok(None);
5419        };
5420        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5421            failure(
5422                NodeFailureCode::BackendOperationFailed,
5423                "session environment materializer is unavailable",
5424            )
5425        })?;
5426        let materialization_failure_code = if context.is_some() {
5427            NodeFailureCode::ContextPackMaterializationFailed
5428        } else if bundle.is_some() {
5429            NodeFailureCode::BundleMaterializationFailed
5430        } else {
5431            NodeFailureCode::BackendOperationFailed
5432        };
5433        let id = MaterializationId::new(format!(
5434            "mat-{}-{}-{}",
5435            self.incarnation_id,
5436            address.session.instance_id.0,
5437            address.session.generation.0,
5438        ))
5439        .map_err(|_| {
5440            failure(
5441                NodeFailureCode::BackendOperationFailed,
5442                "session environment materialization identity failed",
5443            )
5444        })?;
5445        let owner = MaterializationOwner::Session {
5446            incarnation_id: self.incarnation_id,
5447            instance_id: address.session.instance_id,
5448            generation: address.session.generation,
5449        };
5450        let mut ownership = materializer
5451            .begin(
5452                id.clone(),
5453                environment_profile.cloned(),
5454                bundle.cloned(),
5455                context.cloned(),
5456                owner,
5457                managed_lease_id,
5458                &profile,
5459                unix_time_ms(),
5460            )
5461            .map_err(|_| {
5462                failure(
5463                    materialization_failure_code,
5464                    "session materialization preparation failed",
5465                )
5466            })?;
5467        {
5468            let _transaction = self
5469                .state_transaction
5470                .lock()
5471                .unwrap_or_else(|poisoned| poisoned.into_inner());
5472            let duplicate = self
5473                .materializations
5474                .lock()
5475                .unwrap_or_else(|poisoned| poisoned.into_inner())
5476                .insert(id.clone(), ownership.clone());
5477            if duplicate.is_some() {
5478                return Err(failure(
5479                    NodeFailureCode::BackendOperationFailed,
5480                    "session environment materialization identity conflict",
5481                ));
5482            }
5483            if let Err(error) = self.persist_state_locked() {
5484                self.materializations
5485                    .lock()
5486                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5487                    .remove(&id);
5488                return Err(persistence_failure(error));
5489            }
5490        }
5491        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5492            Ok(environment) => environment,
5493            Err(_) => {
5494                let transaction = self
5495                    .state_transaction
5496                    .lock()
5497                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5498                self.materializations
5499                    .lock()
5500                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5501                    .insert(id.clone(), ownership.clone());
5502                if self.persist_state_locked().is_err() {
5503                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5504                }
5505                drop(transaction);
5506                if ownership.state() == MaterializationState::CleanupRequired {
5507                    let _ = self.cleanup_materialization(&id);
5508                }
5509                return Err(failure(
5510                    materialization_failure_code,
5511                    "session materialization failed",
5512                ));
5513            }
5514        };
5515        {
5516            let _transaction = self
5517                .state_transaction
5518                .lock()
5519                .unwrap_or_else(|poisoned| poisoned.into_inner());
5520            self.materializations
5521                .lock()
5522                .unwrap_or_else(|poisoned| poisoned.into_inner())
5523                .insert(id.clone(), ownership.clone());
5524            if let Err(error) = self.persist_state_locked() {
5525                drop(_transaction);
5526                let _ = self.cleanup_materialization(&id);
5527                return Err(persistence_failure(error));
5528            }
5529        }
5530        let guard = SessionMaterializationGuard {
5531            shared: self,
5532            id: Some(id),
5533            provider_home: Some(ownership.provider_home().to_path_buf()),
5534        };
5535        let bundle_arguments = match bundle {
5536            Some(receipt) => {
5537                let catalog = self.bundle_catalog.read()
5538                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5539                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5540                    NodeFailureCode::UnknownBundle,
5541                    "session bundle is unavailable on this node",
5542                ))?;
5543                if installed.receipt() != *receipt {
5544                    return Err(failure(
5545                        NodeFailureCode::BundleBindingMismatch,
5546                        "session bundle revision or digest changed",
5547                    ));
5548                }
5549                let layout = bundle_layout.ok_or_else(|| failure(
5550                    NodeFailureCode::BundleBindingMismatch,
5551                    "materialized bundle provider layout is unavailable",
5552                ))?;
5553                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5554                    failure(
5555                        NodeFailureCode::BundleMaterializationFailed,
5556                        "session bundle byte revalidation failed",
5557                    )
5558                })?;
5559                Some(bundle_launch_arguments(
5560                    layout,
5561                    ownership.bundle_root(),
5562                    ownership.provider_home(),
5563                ).map_err(|_| failure(
5564                    NodeFailureCode::BundleBindingMismatch,
5565                    "session bundle launch binding is invalid",
5566                ))?)
5567            }
5568            None => None,
5569        };
5570        if let Some(receipt) = context {
5571            materializer
5572                .revalidate_context(&ownership, receipt)
5573                .map_err(|_| failure(
5574                    NodeFailureCode::ContextPackMaterializationFailed,
5575                    "session context pack byte revalidation failed",
5576                ))?;
5577        }
5578        let transport = match mode {
5579            SessionMode::Pty => TransportKind::Pty,
5580            SessionMode::Inline => TransportKind::Pipe,
5581            SessionMode::Acp => TransportKind::Acp,
5582        };
5583        let overlay = if let Some(extra_args) = bundle_arguments {
5584            Some(PreparedNativeLaunchOverlay::Instance(
5585                NativeInstanceLaunchOverlay::new(
5586                    provider.clone(),
5587                    transport,
5588                    if has_environment_overlay {
5589                        environment
5590                    } else {
5591                        Vec::new()
5592                    },
5593                    extra_args,
5594                )
5595                .map_err(|_| {
5596                    failure(
5597                        NodeFailureCode::BundleBindingMismatch,
5598                        "session bundle launch overlay is invalid",
5599                    )
5600                })?,
5601            ))
5602        } else if has_environment_overlay {
5603            let context_only = environment_profile.is_none()
5604                && bundle.is_none()
5605                && context.is_some();
5606            let overlay = if context_only {
5607                NativeLaunchEnvironmentOverlay::new_context_root(
5608                    provider.clone(),
5609                    transport,
5610                    environment,
5611                )
5612                .map_err(|_| {
5613                    failure(
5614                        NodeFailureCode::ContextPackMaterializationFailed,
5615                        "context-only launch overlay is invalid",
5616                    )
5617                })?
5618            } else {
5619                NativeLaunchEnvironmentOverlay::new(
5620                    provider.clone(),
5621                    transport,
5622                    environment,
5623                )
5624                .map_err(|_| {
5625                    failure(
5626                        NodeFailureCode::BackendOperationFailed,
5627                        "session environment overlay is invalid",
5628                    )
5629                })?
5630            };
5631            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5632        } else {
5633            None
5634        };
5635        Ok(Some((overlay, guard)))
5636    }
5637
5638    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5639        let Some(mut ownership) = self
5640            .materializations
5641            .lock()
5642            .unwrap_or_else(|poisoned| poisoned.into_inner())
5643            .get(id)
5644            .cloned()
5645        else {
5646            return Ok(());
5647        };
5648        if ownership.state() != MaterializationState::CleanupRequired {
5649            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5650                failure(
5651                    NodeFailureCode::BackendOperationFailed,
5652                    "session environment cleanup state is invalid",
5653                )
5654            })?;
5655        }
5656        {
5657            let _transaction = self
5658                .state_transaction
5659                .lock()
5660                .unwrap_or_else(|poisoned| poisoned.into_inner());
5661            let previous = self
5662                .materializations
5663                .lock()
5664                .unwrap_or_else(|poisoned| poisoned.into_inner())
5665                .insert(id.clone(), ownership.clone());
5666            if let Err(error) = self.persist_state_locked() {
5667                let mut materializations = self
5668                    .materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5671                if let Some(previous) = previous {
5672                    materializations.insert(id.clone(), previous);
5673                }
5674                return Err(persistence_failure(error));
5675            }
5676        }
5677        let cleanup = self
5678            .session_environment_materializer
5679            .as_ref()
5680            .ok_or(())
5681            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5682        if cleanup.is_err() {
5683            let _ = ownership.mark_recovery_required(unix_time_ms());
5684            self.materializations
5685                .lock()
5686                .unwrap_or_else(|poisoned| poisoned.into_inner())
5687                .insert(id.clone(), ownership);
5688            if self.persist_state().is_err() {
5689                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5690            }
5691            return Err(failure(
5692                NodeFailureCode::BackendOperationFailed,
5693                "session environment cleanup requires recovery",
5694            ));
5695        }
5696        let _transaction = self
5697            .state_transaction
5698            .lock()
5699            .unwrap_or_else(|poisoned| poisoned.into_inner());
5700        self.materializations
5701            .lock()
5702            .unwrap_or_else(|poisoned| poisoned.into_inner())
5703            .remove(id);
5704        if let Err(error) = self.persist_state_locked() {
5705            self.materializations
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner())
5708                .insert(id.clone(), ownership);
5709            return Err(persistence_failure(error));
5710        }
5711        Ok(())
5712    }
5713
5714    fn resolve_record_materialization(
5715        &self,
5716        record_id: &SessionRecordId,
5717        provider: &AgentId,
5718        mode: SessionMode,
5719        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5720        bundle: Option<&ResolvedBundleReceipt>,
5721        context: Option<&ResolvedContextPackReceipt>,
5722    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5723        let ownership = {
5724            let materializations = self
5725                .materializations
5726                .lock()
5727                .unwrap_or_else(|poisoned| poisoned.into_inner());
5728            let mut matches = materializations.values().filter(|record| {
5729                record.owner()
5730                    == &MaterializationOwner::Record {
5731                        record_id: record_id.clone(),
5732                    }
5733            });
5734            let ownership = matches.next().cloned();
5735            if matches.next().is_some() {
5736                return Err(failure(
5737                    NodeFailureCode::BackendOperationFailed,
5738                    "managed session has conflicting environment ownership",
5739                ));
5740            }
5741            ownership
5742        };
5743        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5744            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5745                failure(
5746                    NodeFailureCode::ContextPackMaterializationFailed,
5747                    "session context materializer is unavailable",
5748                )
5749            })?;
5750            let pack = materializer
5751                .revalidate_context(ownership, receipt)
5752                .map_err(|_| {
5753                    self.mark_materialization_recovery_required(ownership.id());
5754                    failure(
5755                        NodeFailureCode::ContextPackMaterializationFailed,
5756                        "managed session context pack revalidation failed",
5757                    )
5758                })?;
5759            self.context_catalog
5760                .write()
5761                .unwrap_or_else(|poisoned| poisoned.into_inner())
5762                .insert(pack)
5763                .map_err(|_| failure(
5764                    NodeFailureCode::ContextPackMaterializationFailed,
5765                    "managed session context pack catalog restore failed",
5766                ))?;
5767        }
5768        let materialization = self.materialization_profile(
5769            provider,
5770            mode,
5771            environment_profile,
5772            bundle,
5773            context,
5774        )?;
5775        match (ownership, materialization) {
5776            (None, None) => Ok(None),
5777            (None, Some(_)) | (Some(_), None) => Err(failure(
5778                NodeFailureCode::BackendOperationFailed,
5779                "managed session materialization is unavailable",
5780            )),
5781            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5782                if ownership.environment_profile() != environment_profile
5783                    || ownership.bundle() != bundle
5784                    || ownership.context() != context
5785                    || ownership.state() != MaterializationState::Ready
5786                {
5787                    self.mark_materialization_recovery_required(ownership.id());
5788                    return Err(failure(
5789                        NodeFailureCode::BackendOperationFailed,
5790                        "managed session environment ownership requires recovery",
5791                    ));
5792                }
5793                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5794                    failure(
5795                        NodeFailureCode::BackendOperationFailed,
5796                        "session environment materializer is unavailable",
5797                    )
5798                })?;
5799                if materializer.revalidate(&ownership).is_err() {
5800                    self.mark_materialization_recovery_required(ownership.id());
5801                    return Err(failure(
5802                        NodeFailureCode::BackendOperationFailed,
5803                        "managed session environment revalidation failed",
5804                    ));
5805                }
5806                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5807                    let catalog = self.bundle_catalog.read()
5808                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5809                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5810                        return Err(failure(
5811                            NodeFailureCode::UnknownBundle,
5812                            "managed session bundle is unavailable",
5813                        ));
5814                    };
5815                    let layout = bundle_layout.ok_or_else(|| failure(
5816                        NodeFailureCode::BundleBindingMismatch,
5817                        "record bundle provider layout is unavailable",
5818                    ))?;
5819                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5820                        drop(catalog);
5821                        self.mark_materialization_recovery_required(ownership.id());
5822                        return Err(failure(
5823                            NodeFailureCode::BundleMaterializationFailed,
5824                            "managed session bundle revalidation failed",
5825                        ));
5826                    }
5827                    Some(bundle_launch_arguments(
5828                        layout,
5829                        ownership.bundle_root(),
5830                        ownership.provider_home(),
5831                    ).map_err(|_| failure(
5832                        NodeFailureCode::BundleBindingMismatch,
5833                        "managed session bundle launch binding is invalid",
5834                    ))?)
5835                } else {
5836                    None
5837                };
5838                if !has_environment_materialization {
5839                    let overlay = bundle_arguments.map(|extra_args| {
5840                        NativeInstanceLaunchOverlay::new(
5841                            provider.clone(),
5842                            TransportKind::Pty,
5843                            Vec::new(),
5844                            extra_args,
5845                        )
5846                        .map(PreparedNativeLaunchOverlay::Instance)
5847                        .map_err(|_| failure(
5848                            NodeFailureCode::BundleBindingMismatch,
5849                            "managed session bundle launch overlay is invalid",
5850                        ))
5851                    }).transpose()?;
5852                    return Ok(Some((ownership.id().clone(), overlay)));
5853                }
5854                let environment = match materializer.resolve_environment(&ownership, &profile) {
5855                    Ok(environment) => environment,
5856                    Err(
5857                        SessionEnvironmentMaterializeError::SecretUnavailable
5858                        | SessionEnvironmentMaterializeError::SecretDenied
5859                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5860                    ) => {
5861                        return Err(failure(
5862                            NodeFailureCode::BackendOperationFailed,
5863                            "managed session environment resolution failed",
5864                        ));
5865                    }
5866                    Err(
5867                        SessionEnvironmentMaterializeError::InvalidRoot
5868                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5869                        | SessionEnvironmentMaterializeError::Filesystem(_)
5870                        | SessionEnvironmentMaterializeError::Record(_),
5871                    ) => {
5872                        self.mark_materialization_recovery_required(ownership.id());
5873                        return Err(failure(
5874                            NodeFailureCode::BackendOperationFailed,
5875                            "managed session environment resolution failed",
5876                        ));
5877                    }
5878                };
5879                let transport = match mode {
5880                    SessionMode::Pty => TransportKind::Pty,
5881                    SessionMode::Inline => TransportKind::Pipe,
5882                    SessionMode::Acp => TransportKind::Acp,
5883                };
5884                let overlay = match bundle_arguments {
5885                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5886                        NativeInstanceLaunchOverlay::new(
5887                            provider.clone(),
5888                            transport,
5889                            environment,
5890                            extra_args,
5891                        )
5892                        .map_err(|_| failure(
5893                            NodeFailureCode::BundleBindingMismatch,
5894                            "managed session bundle launch overlay is invalid",
5895                        ))?,
5896                    ),
5897                    None => PreparedNativeLaunchOverlay::Environment(
5898                        NativeLaunchEnvironmentOverlay::new(
5899                            provider.clone(),
5900                            transport,
5901                            environment,
5902                        )
5903                        .map_err(|_| {
5904                            failure(
5905                                NodeFailureCode::BackendOperationFailed,
5906                                "managed session environment overlay is invalid",
5907                            )
5908                        })?,
5909                    ),
5910                };
5911                Ok(Some((ownership.id().clone(), Some(overlay))))
5912            }
5913        }
5914    }
5915
5916    fn destroy_record_materialization(
5917        &self,
5918        record_id: &SessionRecordId,
5919    ) -> Result<bool, NodeFailure> {
5920        let ids = self
5921            .materializations
5922            .lock()
5923            .unwrap_or_else(|poisoned| poisoned.into_inner())
5924            .values()
5925            .filter_map(|record| {
5926                (record.owner()
5927                    == &MaterializationOwner::Record {
5928                        record_id: record_id.clone(),
5929                    })
5930                .then(|| record.id().clone())
5931            })
5932            .collect::<Vec<_>>();
5933        if ids.len() > 1 {
5934            return Err(failure(
5935                NodeFailureCode::BackendOperationFailed,
5936                "managed session has conflicting environment ownership",
5937            ));
5938        }
5939        let Some(id) = ids.first().cloned() else {
5940            return Ok(false);
5941        };
5942        let (terminal_record, mut cleanup_ownership) = {
5943            let _transaction = self
5944                .state_transaction
5945                .lock()
5946                .unwrap_or_else(|poisoned| poisoned.into_inner());
5947            let original_record = self.record(record_id)?;
5948            let mut terminal_record = original_record.clone();
5949            terminal_record.provider_session = None;
5950            terminal_record.active_session = None;
5951            terminal_record.state = ManagedSessionState::Unavailable;
5952            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5953            terminal_record.updated_at_unix_ms = unix_time_ms();
5954            let original_ownership = self
5955                .materializations
5956                .lock()
5957                .unwrap_or_else(|poisoned| poisoned.into_inner())
5958                .get(&id)
5959                .cloned()
5960                .ok_or_else(|| {
5961                    failure(
5962                        NodeFailureCode::BackendOperationFailed,
5963                        "managed session environment ownership disappeared during cleanup",
5964                    )
5965                })?;
5966            let mut cleanup_ownership = original_ownership.clone();
5967            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5968                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5969                    failure(
5970                        NodeFailureCode::BackendOperationFailed,
5971                        "managed session environment cleanup state is invalid",
5972                    )
5973                })?;
5974            }
5975            self.session_records
5976                .lock()
5977                .unwrap_or_else(|poisoned| poisoned.into_inner())
5978                .records
5979                .insert(record_id.clone(), terminal_record.clone());
5980            self.materializations
5981                .lock()
5982                .unwrap_or_else(|poisoned| poisoned.into_inner())
5983                .insert(id.clone(), cleanup_ownership.clone());
5984            if let Err(error) = self.persist_state_locked() {
5985                self.session_records
5986                    .lock()
5987                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5988                    .records
5989                    .insert(record_id.clone(), original_record);
5990                self.materializations
5991                    .lock()
5992                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5993                    .insert(id.clone(), original_ownership);
5994                return Err(persistence_failure(error));
5995            }
5996            (terminal_record, cleanup_ownership)
5997        };
5998        let cleanup = self
5999            .session_environment_materializer
6000            .as_ref()
6001            .ok_or(())
6002            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
6003        if cleanup.is_err() {
6004            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
6005            self.materializations
6006                .lock()
6007                .unwrap_or_else(|poisoned| poisoned.into_inner())
6008                .insert(id.clone(), cleanup_ownership);
6009            if self.persist_state().is_err() {
6010                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6011            }
6012            return Err(failure(
6013                NodeFailureCode::BackendOperationFailed,
6014                "managed session environment cleanup requires recovery",
6015            ));
6016        }
6017        let _transaction = self
6018            .state_transaction
6019            .lock()
6020            .unwrap_or_else(|poisoned| poisoned.into_inner());
6021        self.remove_record_memory(record_id);
6022        self.materializations
6023            .lock()
6024            .unwrap_or_else(|poisoned| poisoned.into_inner())
6025            .remove(&id);
6026        if let Err(error) = self.persist_state_locked() {
6027            self.session_records
6028                .lock()
6029                .unwrap_or_else(|poisoned| poisoned.into_inner())
6030                .records
6031                .insert(record_id.clone(), terminal_record);
6032            self.materializations
6033                .lock()
6034                .unwrap_or_else(|poisoned| poisoned.into_inner())
6035                .insert(id, cleanup_ownership);
6036            return Err(persistence_failure(error));
6037        }
6038        Ok(true)
6039    }
6040
6041    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6042        let changed = {
6043            let mut materializations = self
6044                .materializations
6045                .lock()
6046                .unwrap_or_else(|poisoned| poisoned.into_inner());
6047            let Some(ownership) = materializations.get_mut(id) else {
6048                return;
6049            };
6050            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6051        };
6052        if changed && self.persist_state().is_err() {
6053            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6054        }
6055    }
6056
6057    fn reconcile_materializations(&self) {
6058        let records = self
6059            .materializations
6060            .lock()
6061            .unwrap_or_else(|poisoned| poisoned.into_inner())
6062            .values()
6063            .cloned()
6064            .collect::<Vec<_>>();
6065        for mut ownership in records {
6066            let result = match (ownership.state(), ownership.owner()) {
6067                (MaterializationState::Preparing, _)
6068                | (MaterializationState::CleanupRequired, _) => {
6069                    self.cleanup_materialization(ownership.id())
6070                }
6071                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6072                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6073                        self.materializations
6074                            .lock()
6075                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6076                            .insert(ownership.id().clone(), ownership.clone());
6077                        self.persist_state().map_err(persistence_failure)
6078                    } else {
6079                        Err(failure(
6080                            NodeFailureCode::BackendOperationFailed,
6081                            "session environment reconciliation failed",
6082                        ))
6083                    }
6084                }
6085                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6086                    .session_environment_materializer
6087                    .as_ref()
6088                    .ok_or_else(|| {
6089                        failure(
6090                            NodeFailureCode::BackendOperationFailed,
6091                            "session environment materializer is unavailable",
6092                        )
6093                    })
6094                    .and_then(|materializer| {
6095                        materializer.revalidate(&ownership).map_err(|_| failure(
6096                            NodeFailureCode::BackendOperationFailed,
6097                            "session materialization revalidation failed",
6098                        ))?;
6099                        let record = self.record(record_id)?;
6100                        if record.environment_profile.as_ref() != ownership.environment_profile()
6101                            || record.bundle.as_ref() != ownership.bundle()
6102                            || record.context.as_ref() != ownership.context()
6103                        {
6104                            return Err(failure(
6105                                NodeFailureCode::BackendOperationFailed,
6106                                "session materialization ownership changed after restart",
6107                            ));
6108                        }
6109                        if let Some(receipt) = ownership.bundle() {
6110                            let catalog = self.bundle_catalog.read()
6111                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6112                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6113                                NodeFailureCode::UnknownBundle,
6114                                "session bundle is unavailable after restart",
6115                            ))?;
6116                            if bundle.receipt() != *receipt {
6117                                return Err(failure(
6118                                    NodeFailureCode::BundleBindingMismatch,
6119                                    "session bundle changed after restart",
6120                                ));
6121                            }
6122                            let layout = self.bundle_layout(
6123                                &record.provider,
6124                                record.mode,
6125                                record.environment_profile.as_ref(),
6126                                bundle,
6127                            )?;
6128                            materializer
6129                                .revalidate_bundle(&ownership, bundle, layout)
6130                                .map_err(|_| failure(
6131                                    NodeFailureCode::BundleMaterializationFailed,
6132                                    "session bundle revalidation failed",
6133                                ))?;
6134                        }
6135                        if let Some(receipt) = ownership.context() {
6136                            let pack = materializer
6137                                .revalidate_context(&ownership, receipt)
6138                                .map_err(|_| {
6139                                    failure(
6140                                        NodeFailureCode::ContextPackMaterializationFailed,
6141                                        "session context revalidation failed",
6142                                    )
6143                                })?;
6144                            self.context_catalog
6145                                .write()
6146                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6147                                .insert(pack)
6148                                .map_err(|_| {
6149                                    failure(
6150                                        NodeFailureCode::ContextPackMaterializationFailed,
6151                                        "session context catalog restore failed",
6152                                    )
6153                                })?;
6154                        }
6155                        Ok(())
6156                    }),
6157                (MaterializationState::RecoveryRequired, _) => Ok(()),
6158            };
6159            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6160                let _ = ownership.mark_recovery_required(unix_time_ms());
6161                self.materializations
6162                    .lock()
6163                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6164                    .insert(ownership.id().clone(), ownership);
6165                if self.persist_state().is_err() {
6166                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6167                }
6168            }
6169        }
6170        let materialized_profiles = self
6171            .environment_materialization_profiles
6172            .read()
6173            .unwrap_or_else(|poisoned| poisoned.into_inner())
6174            .keys()
6175            .cloned()
6176            .collect::<Vec<_>>();
6177        let ownership = self
6178            .materializations
6179            .lock()
6180            .unwrap_or_else(|poisoned| poisoned.into_inner())
6181            .values()
6182            .cloned()
6183            .collect::<Vec<_>>();
6184        let changed = {
6185            let mut session_records = self
6186                .session_records
6187                .lock()
6188                .unwrap_or_else(|poisoned| poisoned.into_inner());
6189            let mut changed = false;
6190            for record in session_records.records.values_mut() {
6191                let environment_profile = record.environment_profile.as_ref();
6192                if environment_profile.is_none()
6193                    && record.bundle.is_none()
6194                    && record.context.is_none()
6195                {
6196                    continue;
6197                }
6198                if record.bundle.is_none()
6199                    && environment_profile.is_some_and(|profile| {
6200                        !materialized_profiles.contains(&profile.profile_id)
6201                    })
6202                {
6203                    continue;
6204                }
6205                let has_exact_ownership = ownership.iter().any(|candidate| {
6206                    candidate.state() == MaterializationState::Ready
6207                        && candidate.environment_profile() == environment_profile
6208                        && candidate.bundle() == record.bundle.as_ref()
6209                        && candidate.context() == record.context.as_ref()
6210                        && candidate.owner()
6211                            == &MaterializationOwner::Record {
6212                                record_id: record.record_id.clone(),
6213                            }
6214                });
6215                if !has_exact_ownership {
6216                    record.provider_session = None;
6217                    record.active_session = None;
6218                    record.state = ManagedSessionState::Unavailable;
6219                    record.last_error = Some(if record.bundle.is_some() {
6220                        "bundle-unavailable".to_owned()
6221                    } else if record.context.is_some() {
6222                        "context-unavailable".to_owned()
6223                    } else {
6224                        "environment-profile-unavailable".to_owned()
6225                    });
6226                    record.updated_at_unix_ms = unix_time_ms();
6227                    changed = true;
6228                }
6229            }
6230            changed
6231        };
6232        if changed && self.persist_state().is_err() {
6233            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6234        }
6235    }
6236
6237    fn replay_spawn_spec(
6238        &self,
6239        spec: &SpawnSpec,
6240        now: Instant,
6241    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6242        let mut cache = self
6243            .spawn_idempotency
6244            .lock()
6245            .unwrap_or_else(|poisoned| poisoned.into_inner());
6246        cache.entries.retain(|_, entry| entry.expires_at > now);
6247        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6248            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6249                return Err(failure(
6250                    NodeFailureCode::SpawnIdempotencyConflict,
6251                    "spawn idempotency key was reused across spawn kinds",
6252                ));
6253            };
6254            if existing != spec {
6255                return Err(failure(
6256                    NodeFailureCode::SpawnIdempotencyConflict,
6257                    "spawn idempotency key was reused with a different specification",
6258                ));
6259            }
6260            return Ok(Some(result.clone()));
6261        }
6262        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6263            return Err(failure(
6264                NodeFailureCode::SpawnIdempotencyCapacity,
6265                "spawn idempotency cache reached its bounded capacity",
6266            ));
6267        }
6268        Ok(None)
6269    }
6270
6271    fn remember_spawn_spec(
6272        &self,
6273        spec: SpawnSpec,
6274        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6275        accepted_at: Instant,
6276    ) {
6277        self
6278            .spawn_idempotency
6279            .lock()
6280            .unwrap_or_else(|poisoned| poisoned.into_inner())
6281            .entries
6282            .insert(
6283                spec.idempotency_key.clone(),
6284                SpawnIdempotencyEntry {
6285                    value: SpawnIdempotencyValue::Standard { spec, result },
6286                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6287                },
6288            );
6289    }
6290
6291    fn claim_harness_mcp_spawn(
6292        &self,
6293        _reservation_id: &HarnessMcpReservationId,
6294        _activation_digest: &HarnessMcpActivationDigest,
6295        spec: &SpawnSpec,
6296        now: Instant,
6297    ) -> Result<(), NodeFailure> {
6298        let mut cache = self.spawn_idempotency.lock()
6299            .unwrap_or_else(|poisoned| poisoned.into_inner());
6300        cache.entries.retain(|_, entry| entry.expires_at > now);
6301        if cache.entries.contains_key(&spec.idempotency_key) {
6302            return Err(failure(
6303                NodeFailureCode::SpawnIdempotencyConflict,
6304                "spawn idempotency key was reused across spawn kinds",
6305            ));
6306        }
6307        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308            return Err(failure(
6309                NodeFailureCode::SpawnIdempotencyCapacity,
6310                "spawn idempotency cache reached its bounded capacity",
6311            ));
6312        }
6313        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6314            value: SpawnIdempotencyValue::HarnessMcp,
6315            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6316        });
6317        Ok(())
6318    }
6319
6320    fn replay_managed_spawn(
6321        &self,
6322        request: &ManagedWorktreeSpawnRequest,
6323        now: Instant,
6324    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6325        let mut cache = self.spawn_idempotency.lock()
6326            .unwrap_or_else(|poisoned| poisoned.into_inner());
6327        cache.entries.retain(|_, entry| entry.expires_at > now);
6328        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6329            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6330                return Err(failure(
6331                    NodeFailureCode::SpawnIdempotencyConflict,
6332                    "spawn idempotency key was reused across spawn kinds",
6333                ));
6334            };
6335            if existing != request {
6336                return Err(failure(
6337                    NodeFailureCode::SpawnIdempotencyConflict,
6338                    "spawn idempotency key was reused with a different managed specification",
6339                ));
6340            }
6341            return Ok(Some(result.clone()));
6342        }
6343        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6344            return Err(failure(
6345                NodeFailureCode::SpawnIdempotencyCapacity,
6346                "spawn idempotency cache reached its bounded capacity",
6347            ));
6348        }
6349        Ok(None)
6350    }
6351
6352    fn remember_managed_spawn(
6353        &self,
6354        request: ManagedWorktreeSpawnRequest,
6355        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6356        accepted_at: Instant,
6357    ) {
6358        self.spawn_idempotency.lock()
6359            .unwrap_or_else(|poisoned| poisoned.into_inner())
6360            .entries
6361            .insert(
6362                request.spawn_spec.idempotency_key.clone(),
6363                SpawnIdempotencyEntry {
6364                    value: SpawnIdempotencyValue::Managed { request, result },
6365                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6366                },
6367            );
6368    }
6369
6370    fn remember_managed_spawn_attempt(
6371        &self,
6372        durable_v2_key: Option<&SpawnIdempotencyKey>,
6373        request: ManagedWorktreeSpawnRequest,
6374        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6375        accepted_at: Instant,
6376    ) {
6377        if durable_v2_key.is_none() {
6378            self.remember_managed_spawn(request, result, accepted_at);
6379        }
6380    }
6381
6382    fn acquire_durable_managed_spawn_v2(
6383        &self,
6384        request: &ManagedWorktreeSpawnRequestV2,
6385        request_digest: String,
6386    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6387        if self.state_path.is_none() {
6388            return Err(failure(
6389                NodeFailureCode::BackendOperationFailed,
6390                "managed worktree V2 requires durable node state",
6391            ));
6392        }
6393        let _transaction = self.state_transaction
6394            .lock()
6395            .unwrap_or_else(|poisoned| poisoned.into_inner());
6396        let key = request.spawn_spec.idempotency_key.clone();
6397        let mut previous = None;
6398        {
6399            let mut replays = self.managed_spawn_replays
6400                .lock()
6401                .unwrap_or_else(|poisoned| poisoned.into_inner());
6402            if let Some(record) = replays.get_mut(&key) {
6403                if record.request_digest != request_digest {
6404                    return Err(failure(
6405                        NodeFailureCode::SpawnIdempotencyConflict,
6406                        "managed worktree V2 idempotency key was reused with a different request",
6407                    ));
6408                }
6409                match &record.state {
6410                    ManagedWorktreeSpawnReplayStateV10::Pending {
6411                        owner_incarnation_id,
6412                        lease_id: None,
6413                    } if owner_incarnation_id != &self.incarnation_id => {
6414                        previous = Some(record.clone());
6415                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6416                            owner_incarnation_id: self.incarnation_id,
6417                            lease_id: None,
6418                        };
6419                        record.updated_at_unix_ms = unix_time_ms();
6420                    }
6421                    ManagedWorktreeSpawnReplayStateV10::Pending {
6422                        lease_id: None,
6423                        ..
6424                    } => {
6425                        return Err(failure(
6426                            NodeFailureCode::BackendBusy,
6427                            "managed worktree V2 reservation is already active",
6428                        ));
6429                    }
6430                    ManagedWorktreeSpawnReplayStateV10::Pending {
6431                        lease_id: Some(_),
6432                        ..
6433                    } => {
6434                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6435                    }
6436                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6437                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6438                            receipt.clone(),
6439                        ));
6440                    }
6441                }
6442            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6443                return Err(failure(
6444                    NodeFailureCode::SpawnIdempotencyCapacity,
6445                    "durable managed worktree replay ledger reached its bounded capacity",
6446                ));
6447            } else {
6448                let now = unix_time_ms();
6449                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6450                    idempotency_key: key.clone(),
6451                    request_digest,
6452                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6453                    profile_id: request.worktree_profile_id.clone(),
6454                    expected_profile_revision: request.expected_profile_revision.clone(),
6455                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6456                        owner_incarnation_id: self.incarnation_id,
6457                        lease_id: None,
6458                    },
6459                    created_at_unix_ms: now,
6460                    updated_at_unix_ms: now,
6461                });
6462            }
6463        }
6464        if let Err(error) = self.persist_state_locked() {
6465            let mut replays = self.managed_spawn_replays
6466                .lock()
6467                .unwrap_or_else(|poisoned| poisoned.into_inner());
6468            match previous {
6469                Some(previous) => {
6470                    replays.insert(key, previous);
6471                }
6472                None => {
6473                    replays.remove(&key);
6474                }
6475            }
6476            return Err(persistence_failure(error));
6477        }
6478        Ok(DurableManagedSpawnReplayDecision::Reserved)
6479    }
6480
6481    fn link_durable_managed_spawn_v2_lease_locked(
6482        &self,
6483        key: &SpawnIdempotencyKey,
6484        lease_id: &ManagedWorktreeLeaseId,
6485    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6486        let mut replays = self.managed_spawn_replays
6487            .lock()
6488            .unwrap_or_else(|poisoned| poisoned.into_inner());
6489        let record = replays.get_mut(key).ok_or_else(|| {
6490            failure(
6491                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6492                "managed worktree V2 reservation disappeared before allocation",
6493            )
6494        })?;
6495        let previous = record.clone();
6496        match &mut record.state {
6497            ManagedWorktreeSpawnReplayStateV10::Pending {
6498                lease_id: current,
6499                ..
6500            }
6501                if current.is_none() =>
6502            {
6503                *current = Some(lease_id.clone());
6504                record.updated_at_unix_ms = unix_time_ms();
6505                Ok(previous)
6506            }
6507            _ => Err(failure(
6508                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6509                "managed worktree V2 reservation is not allocation-ready",
6510            )),
6511        }
6512    }
6513
6514    fn restore_durable_managed_spawn_v2_locked(
6515        &self,
6516        key: &SpawnIdempotencyKey,
6517        previous: ManagedWorktreeSpawnReplayRecordV10,
6518    ) {
6519        self.managed_spawn_replays
6520            .lock()
6521            .unwrap_or_else(|poisoned| poisoned.into_inner())
6522            .insert(key.clone(), previous);
6523    }
6524
6525    fn commit_durable_managed_spawn_v2(
6526        &self,
6527        request: &ManagedWorktreeSpawnRequestV2,
6528        receipt: ManagedWorktreeSpawnReceipt,
6529    ) -> Result<(), NodeFailure> {
6530        let _transaction = self.state_transaction
6531            .lock()
6532            .unwrap_or_else(|poisoned| poisoned.into_inner());
6533        let key = &request.spawn_spec.idempotency_key;
6534        let previous = {
6535            let mut replays = self.managed_spawn_replays
6536                .lock()
6537                .unwrap_or_else(|poisoned| poisoned.into_inner());
6538            let record = replays.get_mut(key).ok_or_else(|| {
6539                failure(
6540                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6541                    "managed worktree V2 reservation disappeared before commit",
6542                )
6543            })?;
6544            let previous = record.clone();
6545            match &record.state {
6546                ManagedWorktreeSpawnReplayStateV10::Pending {
6547                    lease_id: Some(lease_id),
6548                    ..
6549                } if lease_id == &receipt.lease.lease_id => {}
6550                _ => {
6551                    return Err(failure(
6552                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6553                        "managed worktree V2 reservation is not linked to the spawned lease",
6554                    ));
6555                }
6556            }
6557            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6558            record.updated_at_unix_ms = unix_time_ms();
6559            previous
6560        };
6561        if let Err(error) = self.persist_state_locked() {
6562            self.restore_durable_managed_spawn_v2_locked(key, previous);
6563            return Err(persistence_failure(error));
6564        }
6565        Ok(())
6566    }
6567
6568    fn remove_unallocated_durable_managed_spawn_v2(
6569        &self,
6570        request: &ManagedWorktreeSpawnRequestV2,
6571    ) -> Result<(), NodeFailure> {
6572        let _transaction = self.state_transaction
6573            .lock()
6574            .unwrap_or_else(|poisoned| poisoned.into_inner());
6575        let key = &request.spawn_spec.idempotency_key;
6576        let removed = {
6577            let mut replays = self.managed_spawn_replays
6578                .lock()
6579                .unwrap_or_else(|poisoned| poisoned.into_inner());
6580            match replays.get(key).map(|record| &record.state) {
6581                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6582                    lease_id: None,
6583                    ..
6584                }) => {
6585                    replays.remove(key)
6586                }
6587                _ => None,
6588            }
6589        };
6590        let Some(removed) = removed else {
6591            return Ok(());
6592        };
6593        if let Err(error) = self.persist_state_locked() {
6594            self.managed_spawn_replays
6595                .lock()
6596                .unwrap_or_else(|poisoned| poisoned.into_inner())
6597                .insert(key.clone(), removed);
6598            return Err(persistence_failure(error));
6599        }
6600        Ok(())
6601    }
6602
6603    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6604        if spec.target.node_id != self.node_id {
6605            return Err(failure(
6606                NodeFailureCode::SpawnTargetMismatch,
6607                "spawn target does not match this node",
6608            ));
6609        }
6610        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6611            failure(
6612                NodeFailureCode::UnknownSpawnProfile,
6613                "spawn profile is unavailable on this node",
6614            )
6615        })?;
6616        let resolved = spec.resolve(defaults).map_err(|error| match error {
6617            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6618                NodeFailureCode::SpawnProfileRevisionMismatch,
6619                "spawn profile revision does not match the loaded profile",
6620            ),
6621            _ => failure(
6622                NodeFailureCode::InvalidRequest,
6623                "spawn specification could not be resolved",
6624            ),
6625        })?;
6626        if resolved.target.worktree_id.is_some() {
6627            self.require_worktree_service(&resolved.target.workspace_id)?;
6628        }
6629        let environment_profile = self.resolve_environment_profile(&resolved)?;
6630        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6631        self.resolve_context(&resolved)?;
6632        Ok(resolved)
6633    }
6634
6635    async fn resolve_spawn_workspace(
6636        &self,
6637        resolved: &ResolvedSpawnSpec,
6638        deadline: Instant,
6639    ) -> Result<WorkspaceId, NodeFailure> {
6640        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6641            return Ok(resolved.target.workspace_id.clone());
6642        };
6643        if self.managed_worktrees.lock()
6644            .unwrap_or_else(|poisoned| poisoned.into_inner())
6645            .lease_for_workspace(worktree_id)
6646            .is_some()
6647        {
6648            return Err(failure(
6649                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6650                "managed worktree targets may only be spawned through their lease request",
6651            ));
6652        }
6653        self.require_worktree_service(&resolved.target.workspace_id)?;
6654        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6655        let selected_root = self.workspace_root(worktree_id)?;
6656        let remaining = spawn_deadline_remaining(deadline)?;
6657        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6658            .await
6659            .map_err(|_| {
6660                failure(
6661                    NodeFailureCode::SpawnDeadlineExceeded,
6662                    "Git worktree selection exceeded the spawn deadline",
6663                )
6664            })?
6665            .map_err(git_worktree_failure)?;
6666        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6667        Ok(worktree_id.clone())
6668    }
6669
6670    async fn spawn_from_spec(
6671        &self,
6672        spec: SpawnSpec,
6673    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6674        self.spawn_from_spec_with_proxy(spec, None).await
6675    }
6676
6677    async fn spawn_from_spec_with_proxy(
6678        &self,
6679        spec: SpawnSpec,
6680        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6681    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6682        let accepted_at = Instant::now();
6683        if harness_mcp.is_none() {
6684            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6685                return replayed;
6686            }
6687        }
6688        let resolved = self.resolve_spawn_spec(&spec)?;
6689        let environment_profile = self.resolve_environment_profile(&resolved)?;
6690        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6691        let context = self.resolve_context(&resolved)?;
6692        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6693        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6694        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6695        let spawn_workspace_id = match spawn_workspace_id {
6696            Ok(workspace_id) => workspace_id,
6697            Err(error) => {
6698                let result = Err(error);
6699                if harness_mcp.is_none() {
6700                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6701                }
6702                return result;
6703            }
6704        };
6705        if self.managed_worktrees.lock()
6706            .unwrap_or_else(|poisoned| poisoned.into_inner())
6707            .lease_for_workspace(&spawn_workspace_id)
6708            .is_some()
6709        {
6710            let result = Err(failure(
6711                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6712                "managed worktree targets may only be spawned through their lease request",
6713            ));
6714            if harness_mcp.is_none() {
6715                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6716            }
6717            return result;
6718        }
6719        let result = self
6720            .spawn_session_with_deadline(
6721                spawn_workspace_id,
6722                resolved.provider.clone(),
6723                resolved.mode,
6724                resolved.terminal_size,
6725                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6726                environment_profile.clone(),
6727                bundle.clone(),
6728                context.clone(),
6729                None,
6730                None,
6731                SpawnRecordPolicy::Always,
6732                Some(deadline),
6733                &required_capabilities,
6734                harness_mcp,
6735                resolved.approval_level,
6736                resolved.network_allowlist.clone(),
6737                resolved.browser_profile_id.clone(),
6738            )
6739            .await
6740            .map(|(session, _runtime_policy)| {
6741                resolved.receipt_with_materialization(
6742                    self.incarnation_id,
6743                    session,
6744                    environment_profile,
6745                    bundle,
6746                    context,
6747                )
6748            });
6749        if harness_mcp.is_none() {
6750            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6751        }
6752        result
6753    }
6754
6755    async fn spawn_from_spec_with_harness_mcp(
6756        &self,
6757        reservation_id: HarnessMcpReservationId,
6758        activation_digest: HarnessMcpActivationDigest,
6759        spec: SpawnSpec,
6760        deadline_unix_ms: u64,
6761    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6762        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6763            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6764        })?;
6765        if let Some(receipt) = registry
6766            .replay_spawn(&reservation_id, &activation_digest, &spec)
6767            .map_err(harness_mcp_failure)?
6768        {
6769            return Ok(receipt);
6770        }
6771        let resolved = self.resolve_spawn_spec(&spec)?;
6772        let prepared = registry
6773            .prepare_spawn(
6774                &reservation_id,
6775                &activation_digest,
6776                &spec,
6777                deadline_unix_ms,
6778                resolved.provider.clone(),
6779            )
6780            .map_err(harness_mcp_failure)?;
6781        self.claim_harness_mcp_spawn(
6782            &reservation_id,
6783            &activation_digest,
6784            &spec,
6785            Instant::now(),
6786        )?;
6787        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6788        let mut receipt = match spawn {
6789            Ok(receipt) => receipt,
6790            Err(error) => {
6791                let _ = registry.abort(&reservation_id, &activation_digest);
6792                return Err(error);
6793            }
6794        };
6795        let record_id = self
6796            .session_bindings
6797            .lock()
6798            .unwrap_or_else(|poisoned| poisoned.into_inner())
6799            .get(&receipt.session.session.instance_id)
6800            .and_then(|binding| binding.record_id.clone());
6801        let record_id = match record_id {
6802            Some(record_id) => record_id,
6803            None => {
6804                let rollback = self.remove_session(&receipt.session).await;
6805                let _ = registry.abort(&reservation_id, &activation_digest);
6806                if let Err(rollback_error) = rollback {
6807                    return Err(rollback_error);
6808                }
6809                return Err(failure(
6810                    NodeFailureCode::BindingMismatch,
6811                    "harness MCP spawn did not retain its exact managed record",
6812                ));
6813            }
6814        };
6815        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6816            reservation_id: reservation_id.clone(),
6817            activation_digest: activation_digest.clone(),
6818        });
6819        if let Err(error) = registry.mark_spawned(
6820            &prepared,
6821            receipt.session.clone(),
6822            record_id,
6823            receipt.clone(),
6824        ) {
6825            let rollback = self.remove_session(&receipt.session).await;
6826            let _ = registry.abort(&reservation_id, &activation_digest);
6827            if let Err(rollback_error) = rollback {
6828                return Err(rollback_error);
6829            }
6830            return Err(harness_mcp_failure(error));
6831        }
6832        Ok(receipt)
6833    }
6834
6835    async fn spawn_managed_worktree(
6836        &self,
6837        request: ManagedWorktreeSpawnRequest,
6838    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6839        self.spawn_managed_worktree_inner(request, None).await
6840    }
6841
6842    async fn spawn_managed_worktree_inner(
6843        &self,
6844        request: ManagedWorktreeSpawnRequest,
6845        durable_v2_key: Option<&SpawnIdempotencyKey>,
6846    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6847        let accepted_at = Instant::now();
6848        if durable_v2_key.is_none() {
6849            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6850                return replayed;
6851            }
6852        }
6853        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6854        if request.spawn_spec.target.worktree_id.is_some() {
6855            let result = Err(failure(
6856                NodeFailureCode::InvalidRequest,
6857                "managed worktree spawn must not select a caller-provided worktree",
6858            ));
6859            self.remember_managed_spawn_attempt(
6860                durable_v2_key,
6861                request,
6862                result.clone(),
6863                accepted_at,
6864            );
6865            return result;
6866        }
6867        let environment_profile = self.resolve_environment_profile(&resolved)?;
6868        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6869        let context = self.resolve_context(&resolved)?;
6870        let source_workspace_id = resolved.target.workspace_id.clone();
6871        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6872        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6873        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6874        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6875            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6876            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6877            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6878            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6879        };
6880        let runtime_policy = timeout(
6881            spawn_deadline_remaining(deadline)?,
6882            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6883        ).await.map_err(|_| failure(
6884            NodeFailureCode::SpawnDeadlineExceeded,
6885            "provider admission exceeded the managed spawn deadline",
6886        ))??;
6887        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6888            return Err(failure(
6889                NodeFailureCode::UnsupportedSpawnCapability,
6890                "provider runtime does not admit a required spawn capability",
6891            ));
6892        }
6893        self.ensure_binding_capacity()?;
6894        let source_root = self.workspace_root(&source_workspace_id)?;
6895        profile.validate_for_workspace(&source_root)
6896            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6897        let base_timeout_ms = u64::try_from(
6898            spawn_deadline_remaining(deadline)?.as_millis(),
6899        ).unwrap_or(u64::MAX).max(1);
6900        let base_commit = resolve_base_commit_with_timeout(
6901            &source_root,
6902            profile.base(),
6903            base_timeout_ms,
6904        ).await.map_err(|error| {
6905            if Instant::now() >= deadline || error.message.contains("timed out") {
6906                failure(
6907                    NodeFailureCode::SpawnDeadlineExceeded,
6908                    "managed worktree base resolution exceeded the spawn deadline",
6909                )
6910            } else {
6911                managed_git_worktree_failure(error)
6912            }
6913        })?;
6914
6915        let lease = {
6916            let _transaction = self.state_transaction.lock()
6917                .unwrap_or_else(|poisoned| poisoned.into_inner());
6918            let previous = self.managed_worktrees.lock()
6919                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6920            let lease = self.managed_worktrees.lock()
6921                .unwrap_or_else(|poisoned| poisoned.into_inner())
6922                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6923                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6924            let previous_replay = match durable_v2_key
6925                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6926                .transpose()
6927            {
6928                Ok(previous) => previous,
6929                Err(error) => {
6930                    *self.managed_worktrees.lock()
6931                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6932                    return Err(error);
6933                }
6934            };
6935            if let Err(error) = self.persist_state_locked() {
6936                *self.managed_worktrees.lock()
6937                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6938                if let (Some(key), Some(previous_replay)) =
6939                    (durable_v2_key, previous_replay)
6940                {
6941                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6942                }
6943                return Err(persistence_failure(error));
6944            }
6945            lease
6946        };
6947        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6948        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6949            self.managed_worktrees.lock()
6950                .unwrap_or_else(|poisoned| poisoned.into_inner())
6951                .remove_unmutated(&lease.lease_id);
6952            if self.persist_state().is_err() {
6953                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6954            }
6955            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6956            self.remember_managed_spawn_attempt(
6957                durable_v2_key,
6958                request,
6959                result.clone(),
6960                accepted_at,
6961            );
6962            return result;
6963        }
6964        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6965            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6966            Err(error) => {
6967                self.reconcile_failed_allocation(&lease, &source_root).await;
6968                let result = Err(error);
6969                self.remember_managed_spawn_attempt(
6970                    durable_v2_key,
6971                    request,
6972                    result.clone(),
6973                    accepted_at,
6974                );
6975                return result;
6976            }
6977        };
6978        let created = match create_git_worktree_with_timeout(
6979                &source_root,
6980                &lease.target_root,
6981                &lease.branch,
6982                Some(&base_commit),
6983                mutation_timeout_ms,
6984            ).await {
6985            Ok(created) => created,
6986            Err(error) => {
6987                self.reconcile_failed_allocation(&lease, &source_root).await;
6988                let result = Err(if Instant::now() >= deadline
6989                    || error.message.contains("timed out")
6990                    || error.message.contains("deadline")
6991                {
6992                    failure(
6993                        NodeFailureCode::SpawnDeadlineExceeded,
6994                        "managed worktree creation exceeded the spawn deadline",
6995                    )
6996                } else {
6997                    managed_git_worktree_failure(error)
6998                });
6999                self.remember_managed_spawn_attempt(
7000                    durable_v2_key,
7001                    request,
7002                    result.clone(),
7003                    accepted_at,
7004                );
7005                return result;
7006            }
7007        };
7008        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7009            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7010            let result = Err(failure(
7011                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7012                "created worktree identity did not match its durable lease",
7013            ));
7014            self.remember_managed_spawn_attempt(
7015                durable_v2_key,
7016                request,
7017                result.clone(),
7018                accepted_at,
7019            );
7020            return result;
7021        }
7022        {
7023            let mut registry = self.managed_worktrees.lock()
7024                .unwrap_or_else(|poisoned| poisoned.into_inner());
7025            let current = registry.get_mut(&lease.lease_id)
7026                .expect("allocated managed worktree lease remains present");
7027            current.expected_head = Some(created.head.clone());
7028            current.state = ManagedWorktreeLeaseState::Ready;
7029            current.updated_at_unix_ms = unix_time_ms();
7030        }
7031        if let Err(error) = self.persist_state() {
7032            self.mark_managed_recovery_required(
7033                &lease.lease_id,
7034                ManagedWorktreeCleanupFailure::Backend,
7035            );
7036            let result = Err(persistence_failure(error));
7037            self.remember_managed_spawn_attempt(
7038                durable_v2_key,
7039                request,
7040                result.clone(),
7041                accepted_at,
7042            );
7043            return result;
7044        }
7045        let ready = self.managed_worktrees.lock()
7046            .unwrap_or_else(|poisoned| poisoned.into_inner())
7047            .get(&lease.lease_id)
7048            .expect("ready managed worktree lease remains present")
7049            .snapshot();
7050        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7051        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7052            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7053            let result = Err(error);
7054            self.remember_managed_spawn_attempt(
7055                durable_v2_key,
7056                request,
7057                result.clone(),
7058                accepted_at,
7059            );
7060            return result;
7061        }
7062        let spawn = self.spawn_session_with_deadline(
7063            lease.workspace_id.clone(),
7064            resolved.provider.clone(),
7065            resolved.mode,
7066            resolved.terminal_size,
7067            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7068            environment_profile.clone(),
7069            bundle.clone(),
7070            context.clone(),
7071            Some(lease.lease_id.clone()),
7072            Some(runtime_policy),
7073            SpawnRecordPolicy::Always,
7074            Some(deadline),
7075            &required_capabilities,
7076            None,
7077            resolved.approval_level,
7078            resolved.network_allowlist.clone(),
7079            resolved.browser_profile_id.clone(),
7080        ).await;
7081        let (session, effective_runtime_policy) = match spawn {
7082            Ok(spawned) => spawned,
7083            Err(error) => {
7084                if self
7085                    .cleanup_managed_worktree(&lease.lease_id, true)
7086                    .await
7087                    .is_err()
7088                {
7089                    self.mark_managed_recovery_required(
7090                        &lease.lease_id,
7091                        ManagedWorktreeCleanupFailure::Backend,
7092                    );
7093                }
7094                let result = Err(error);
7095                self.remember_managed_spawn_attempt(
7096                    durable_v2_key,
7097                    request,
7098                    result.clone(),
7099                    accepted_at,
7100                );
7101                return result;
7102            }
7103        };
7104        let (record_id, raw_inventory_record_id) = {
7105            let mut bindings = self.session_bindings.lock()
7106                .unwrap_or_else(|poisoned| poisoned.into_inner());
7107            let binding = bindings.get_mut(&session.session.instance_id)
7108                .expect("successful spawn retains its session binding");
7109            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7110            managed_spawn_record_ownership(binding, effective_runtime_policy)
7111        };
7112        let bound = self.managed_worktrees.lock()
7113            .unwrap_or_else(|poisoned| poisoned.into_inner())
7114            .bind_session(
7115                &lease.lease_id,
7116                ManagedWorktreeSessionHolder {
7117                    incarnation_id: self.incarnation_id,
7118                    instance_id: session.session.instance_id,
7119                    generation: session.session.generation,
7120                },
7121                record_id,
7122                unix_time_ms(),
7123            )
7124            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7125        let snapshot = match bound {
7126            Ok(snapshot) => snapshot,
7127            Err(error) => {
7128                if raw_inventory_record_id
7129                    .as_ref()
7130                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7131                {
7132                    self.mark_managed_recovery_required(
7133                        &lease.lease_id,
7134                        ManagedWorktreeCleanupFailure::Backend,
7135                    );
7136                }
7137                let _ = self.remove_session(&session).await;
7138                self.mark_managed_recovery_required(
7139                    &lease.lease_id,
7140                    ManagedWorktreeCleanupFailure::Backend,
7141                );
7142                let result = Err(error);
7143                self.remember_managed_spawn_attempt(
7144                    durable_v2_key,
7145                    request,
7146                    result.clone(),
7147                    accepted_at,
7148                );
7149                return result;
7150            }
7151        };
7152        if let Err(error) = self.persist_state() {
7153            if raw_inventory_record_id
7154                .as_ref()
7155                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7156            {
7157                self.mark_managed_recovery_required(
7158                    &lease.lease_id,
7159                    ManagedWorktreeCleanupFailure::Backend,
7160                );
7161            }
7162            let _ = self.remove_session(&session).await;
7163            if self
7164                .cleanup_managed_worktree(&lease.lease_id, true)
7165                .await
7166                .is_err()
7167            {
7168                self.mark_managed_recovery_required(
7169                    &lease.lease_id,
7170                    ManagedWorktreeCleanupFailure::Backend,
7171                );
7172            }
7173            let result = Err(persistence_failure(error));
7174            self.remember_managed_spawn_attempt(
7175                durable_v2_key,
7176                request,
7177                result.clone(),
7178                accepted_at,
7179            );
7180            return result;
7181        }
7182        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7183        let receipt = managed_spawn_receipt(
7184            &resolved,
7185            self.incarnation_id,
7186            session,
7187            snapshot,
7188            environment_profile,
7189            bundle,
7190            context,
7191        );
7192        let result = Ok(receipt);
7193        self.remember_managed_spawn_attempt(
7194            durable_v2_key,
7195            request,
7196            result.clone(),
7197            accepted_at,
7198        );
7199        result
7200    }
7201
7202    async fn spawn_managed_worktree_v2(
7203        &self,
7204        request: ManagedWorktreeSpawnRequestV2,
7205    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7206        if request.spawn_spec.target.node_id != self.node_id {
7207            return Err(failure(
7208                NodeFailureCode::SpawnTargetMismatch,
7209                "spawn target does not match this node",
7210            ));
7211        }
7212        if request.spawn_spec.target.worktree_id.is_some() {
7213            return Err(failure(
7214                NodeFailureCode::InvalidRequest,
7215                "managed worktree spawn must not select a caller-provided worktree",
7216            ));
7217        }
7218        let request_digest = managed_spawn_request_digest_v2(&request)?;
7219        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7220        match replay {
7221            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7222            DurableManagedSpawnReplayDecision::PendingLinked => {
7223                return Err(failure(
7224                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7225                    "managed worktree V2 reservation has an incomplete durable allocation",
7226                ));
7227            }
7228            DurableManagedSpawnReplayDecision::Reserved => {}
7229        }
7230        let validated = (|| {
7231            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7232            let profile = self.managed_profile(
7233                &resolved.target.workspace_id,
7234                &request.worktree_profile_id,
7235            )?;
7236            if profile.revision() != &request.expected_profile_revision {
7237                return Err(failure(
7238                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7239                    "managed worktree profile revision does not match the loaded profile",
7240                ));
7241            }
7242            Ok(())
7243        })();
7244        match validated {
7245            Ok(()) => {}
7246            Err(error) => {
7247                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248                return Err(error);
7249            }
7250        }
7251        let legacy = ManagedWorktreeSpawnRequest {
7252            spawn_spec: request.spawn_spec.clone(),
7253            worktree_profile_id: request.worktree_profile_id.clone(),
7254        };
7255        match self
7256            .spawn_managed_worktree_inner(
7257                legacy,
7258                Some(&request.spawn_spec.idempotency_key),
7259            )
7260            .await
7261        {
7262            Ok(receipt) => {
7263                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7264                Ok(receipt)
7265            }
7266            Err(error) => {
7267                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7268                Err(error)
7269            }
7270        }
7271    }
7272
7273    fn managed_profile(
7274        &self,
7275        workspace_id: &WorkspaceId,
7276        profile_id: &WorktreeProfileId,
7277    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7278        if self.worktree_service_modes.get(workspace_id).copied()
7279            != Some(WorktreeServiceMode::Managed)
7280        {
7281            return Err(failure(
7282                NodeFailureCode::UnsupportedSpawnCapability,
7283                "workspace does not enable managed worktrees",
7284            ));
7285        }
7286        self.managed_worktree_profiles.get(workspace_id)
7287            .and_then(|profiles| profiles.get(profile_id))
7288            .cloned()
7289            .ok_or_else(|| failure(
7290                NodeFailureCode::InvalidRequest,
7291                "managed worktree profile is unavailable for this workspace",
7292            ))
7293    }
7294
7295    async fn reconcile_failed_allocation(
7296        &self,
7297        lease: &ManagedWorktreeLeaseRecord,
7298        source_root: &str,
7299    ) {
7300        match list_git_worktrees(source_root).await {
7301            Ok(worktrees) => {
7302                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7303                    None => {
7304                        match std::fs::symlink_metadata(&lease.target_root) {
7305                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7306                                self.managed_worktrees.lock()
7307                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7308                                    .remove_unmutated(&lease.lease_id);
7309                                let _ = self.persist_state();
7310                            }
7311                            _ => self.mark_managed_recovery_required(
7312                                &lease.lease_id,
7313                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7314                            ),
7315                        }
7316                    }
7317                    Some(item) if exact_owned_worktree(
7318                        lease,
7319                        &item.path,
7320                        item.branch.as_deref(),
7321                        &item.head,
7322                    ) => {
7323                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7324                    }
7325                    Some(_) => self.mark_managed_recovery_required(
7326                        &lease.lease_id,
7327                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7328                    ),
7329                }
7330            }
7331            Err(_) => self.mark_managed_recovery_required(
7332                &lease.lease_id,
7333                ManagedWorktreeCleanupFailure::Backend,
7334            ),
7335        }
7336    }
7337
7338    async fn reconcile_managed_worktrees(&self) {
7339        let leases = self.managed_worktrees.lock()
7340            .unwrap_or_else(|poisoned| poisoned.into_inner())
7341            .active_records()
7342            .cloned()
7343            .collect::<Vec<_>>();
7344        for lease in leases {
7345            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7346                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7347                _ => {
7348                    self.mark_managed_recovery_required(
7349                        &lease.lease_id,
7350                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7351                    );
7352                    continue;
7353                }
7354            };
7355            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7356                self.mark_managed_recovery_required(
7357                    &lease.lease_id,
7358                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7359                );
7360                continue;
7361            }
7362            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7363                Ok(root) => root,
7364                Err(_) => {
7365                    self.mark_managed_recovery_required(
7366                        &lease.lease_id,
7367                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7368                    );
7369                    continue;
7370                }
7371            };
7372            let worktrees = match list_git_worktrees(&source_root).await {
7373                Ok(worktrees) => worktrees,
7374                Err(_) => {
7375                    self.mark_managed_recovery_required(
7376                        &lease.lease_id,
7377                        ManagedWorktreeCleanupFailure::Backend,
7378                    );
7379                    continue;
7380                }
7381            };
7382            let listed = worktrees.iter()
7383                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7384            let Some(listed) = listed else {
7385                match std::fs::symlink_metadata(&lease.target_root) {
7386                    Err(error) if error.kind() == io::ErrorKind::NotFound
7387                        && lease.state == ManagedWorktreeLeaseState::Allocating
7388                        && !lease.has_holders() => {
7389                            let removed = self.managed_worktrees.lock()
7390                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7391                                .tombstone(&lease.lease_id, unix_time_ms());
7392                            if self.persist_state().is_ok() && removed.is_some() {
7393                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7394                                    lease_id: lease.lease_id.clone(),
7395                                });
7396                            }
7397                        }
7398                    _ => self.mark_managed_recovery_required(
7399                        &lease.lease_id,
7400                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7401                    ),
7402                }
7403                continue;
7404            };
7405            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7406                &lease,
7407                &listed.path,
7408                listed.branch.as_deref(),
7409                &listed.head,
7410            ) {
7411                self.mark_managed_recovery_required(
7412                    &lease.lease_id,
7413                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7414                );
7415                continue;
7416            }
7417            if !self.workspaces.read()
7418                .unwrap_or_else(|poisoned| poisoned.into_inner())
7419                .contains_key(&lease.workspace_id)
7420            {
7421                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7422                    self.mark_managed_recovery_required(
7423                        &lease.lease_id,
7424                        ManagedWorktreeCleanupFailure::Backend,
7425                    );
7426                    continue;
7427                }
7428            }
7429            let snapshot = {
7430                let mut registry = self.managed_worktrees.lock()
7431                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7432                let current = registry.get_mut(&lease.lease_id)
7433                    .expect("reconciled managed lease remains present");
7434                current.expected_head = Some(listed.head.clone());
7435                current.cleanup_failure = None;
7436                current.state = if current.has_holders() {
7437                    ManagedWorktreeLeaseState::InUse
7438                } else if current.retention == ManagedWorktreeRetention::Retain {
7439                    ManagedWorktreeLeaseState::Retained
7440                } else {
7441                    ManagedWorktreeLeaseState::Ready
7442                };
7443                current.updated_at_unix_ms = unix_time_ms();
7444                current.snapshot()
7445            };
7446            if self.persist_state().is_err() {
7447                self.mark_managed_recovery_required(
7448                    &lease.lease_id,
7449                    ManagedWorktreeCleanupFailure::Backend,
7450                );
7451                continue;
7452            }
7453            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7454            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7455                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7456            }
7457        }
7458    }
7459
7460    /// Crash-recovery startup sweep: durably capture the pack for every
7461    /// `Live` managed record whose most recent session has not yet produced
7462    /// one (recovers a session whose `Exited{0}` event was observed but the
7463    /// Node crashed before the export finished). The reactive fast path for
7464    /// a single just-exited record does not go through this — see
7465    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7466    /// it can capture the exact `SessionAddress` before `publish_control`
7467    /// clears it, rather than re-deriving it from (by then likely already
7468    /// `Dormant`) record state the way a rescan naturally would.
7469    ///
7470    /// Returns as soon as every eligible record's export has been *spawned*,
7471    /// not completed — each one runs on its own detached task (see
7472    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7473    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7474    /// point this is called from `NodeServer::run()`, so nothing could ever
7475    /// drain the history round trip if this awaited it inline here either).
7476    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7477    /// is a silent skip for THIS record — an unusable source or a racing
7478    /// record is not surfaced to any caller.
7479    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7480        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7481            .session_records
7482            .lock()
7483            .unwrap_or_else(|poisoned| poisoned.into_inner())
7484            .records
7485            .values()
7486            .filter(|record| {
7487                record.state == ManagedSessionState::Live
7488                    && record.exported_context.is_none()
7489                    && record.provider_session.is_some()
7490            })
7491            .filter_map(|record| {
7492                record
7493                    .active_session
7494                    .clone()
7495                    .map(|session| (record.record_id.clone(), session))
7496            })
7497            .collect();
7498        for (record_id, session) in candidates {
7499            let shared = Arc::clone(self);
7500            tokio::spawn(async move {
7501                shared
7502                    .reconcile_context_pack_export_for_record(&record_id, session)
7503                    .await;
7504            });
7505        }
7506    }
7507
7508    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7509    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7510    /// round trip this performs (`discover_history`/`load_history`, via
7511    /// `export_context_pack_for_session_record`) can only ever settle once
7512    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7513    /// completion — and `tick()` is exclusively owned by, and only ever
7514    /// called from, that same drive-loop task. Awaiting this inline there
7515    /// would starve `tick()` and this call could never observe its own
7516    /// command complete.
7517    ///
7518    /// Because this now genuinely races the drive loop's own
7519    /// `publish_control` (which downgrades this exact record from `Live` to
7520    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7521    /// triggered this reconcile), the record legitimately may already be
7522    /// `Dormant` by the time this reads it, or may transition mid-flight —
7523    /// `allow_clean_detachment: true` on the downstream export call is what
7524    /// tolerates exactly that transition (and only that transition; see
7525    /// `session_record_export_target_matches`).
7526    async fn reconcile_context_pack_export_for_record(
7527        &self,
7528        record_id: &SessionRecordId,
7529        session: SessionAddress,
7530    ) {
7531        let Ok(record) = self.record(record_id) else {
7532            return;
7533        };
7534        if !matches!(
7535            record.state,
7536            ManagedSessionState::Live | ManagedSessionState::Dormant
7537        ) || record.exported_context.is_some()
7538            || record.provider_session.is_none()
7539        {
7540            return;
7541        }
7542        // Pack bytes are already durably committed inside this call, via
7543        // commit_context_pack_for_session_record's own durable
7544        // write-through (context_pack_store) — no separate step here.
7545        // Unclean exits (non-zero/forced/no loadable history) are already
7546        // rejected by context_pack_source_status_is_usable inside
7547        // materialize_context_pack: this simply returns Err and is skipped.
7548        let Ok(receipt) = self
7549            .export_context_pack_for_session_record(&record.record_id, &session, true)
7550            .await
7551        else {
7552            return;
7553        };
7554        self.set_exported_context(&record.record_id, receipt);
7555    }
7556
7557    /// Records the record's most recent clean-exit pack. No-op (not an
7558    /// error) if the record disappeared, or if it already carries an
7559    /// `exported_context` — first-write-wins, matching the harness-side
7560    /// idempotent reconciliation that consumes this field.
7561    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7562        let transaction = self
7563            .state_transaction
7564            .lock()
7565            .unwrap_or_else(|poisoned| poisoned.into_inner());
7566        let (previous, updated) = {
7567            let mut records = self
7568                .session_records
7569                .lock()
7570                .unwrap_or_else(|poisoned| poisoned.into_inner());
7571            let Some(record) = records.records.get_mut(record_id) else {
7572                return;
7573            };
7574            if record.exported_context.is_some() {
7575                return;
7576            }
7577            let previous = record.clone();
7578            record.exported_context = Some(receipt);
7579            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7580            (previous, record.clone())
7581        };
7582        if self.persist_state_locked().is_err() {
7583            self.session_records
7584                .lock()
7585                .unwrap_or_else(|poisoned| poisoned.into_inner())
7586                .records
7587                .insert(record_id.clone(), previous);
7588            return;
7589        }
7590        drop(transaction);
7591        self.publish_record(updated);
7592    }
7593
7594    /// Resolves the managed record and its exact `SessionAddress` currently
7595    /// bound to `instance_id` at exactly `generation`, used by the reactive
7596    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7597    /// record without a full rescan. Mirrors the lookup already inlined in
7598    /// `publish_control`. Captured synchronously, before `publish_control`
7599    /// for the same event clears the record's own `active_session` — the
7600    /// caller passes the resolved address through to a detached
7601    /// reconciliation task rather than re-deriving it from the record later,
7602    /// after that race has necessarily already happened.
7603    fn managed_record_export_target_for_instance(
7604        &self,
7605        instance_id: AgentInstanceId,
7606        generation: SessionGeneration,
7607    ) -> Option<(SessionRecordId, SessionAddress)> {
7608        let bindings = self
7609            .session_bindings
7610            .lock()
7611            .unwrap_or_else(|poisoned| poisoned.into_inner());
7612        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7613        let record_id = binding.record_id.clone()?;
7614        Some((
7615            record_id,
7616            SessionAddress {
7617                workspace_id: binding.workspace_id.clone(),
7618                session: SessionKey { instance_id, generation },
7619            },
7620        ))
7621    }
7622
7623    fn mark_managed_recovery_required(
7624        &self,
7625        lease_id: &ManagedWorktreeLeaseId,
7626        failure_kind: ManagedWorktreeCleanupFailure,
7627    ) {
7628        let snapshot = {
7629            let mut registry = self.managed_worktrees.lock()
7630                .unwrap_or_else(|poisoned| poisoned.into_inner());
7631            let Some(lease) = registry.get_mut(lease_id) else { return };
7632            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7633            lease.cleanup_failure = Some(failure_kind);
7634            lease.updated_at_unix_ms = unix_time_ms();
7635            lease.snapshot()
7636        };
7637        let _ = self.persist_state();
7638        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7639    }
7640
7641    fn set_persistence_error(&self, error: Option<String>) {
7642        *self
7643            .persistence_error
7644            .write()
7645            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7646            .map(|message| session_registry::sanitized_persistence_summary(&message));
7647    }
7648
7649    fn persist_state(&self) -> io::Result<()> {
7650        let _transaction = self
7651            .state_transaction
7652            .lock()
7653            .unwrap_or_else(|poisoned| poisoned.into_inner());
7654        self.persist_state_locked()
7655    }
7656
7657    fn persist_state_locked(&self) -> io::Result<()> {
7658        let workspaces = self
7659            .workspaces
7660            .read()
7661            .unwrap_or_else(|poisoned| poisoned.into_inner())
7662            .clone();
7663        let records = self
7664            .session_records
7665            .lock()
7666            .unwrap_or_else(|poisoned| poisoned.into_inner())
7667            .records
7668            .values()
7669            .cloned()
7670            .collect::<Vec<_>>();
7671        let mut managed = self.managed_worktrees
7672            .lock()
7673            .unwrap_or_else(|poisoned| poisoned.into_inner());
7674        managed.reattach_record_holders(
7675            &managed_worktree_record_holders(&records),
7676            unix_time_ms(),
7677        );
7678        let materializations = self
7679            .materializations
7680            .lock()
7681            .unwrap_or_else(|poisoned| poisoned.into_inner())
7682            .values()
7683            .cloned()
7684            .collect::<Vec<_>>();
7685        let managed_spawn_replays = self.managed_spawn_replays
7686            .lock()
7687            .unwrap_or_else(|poisoned| poisoned.into_inner())
7688            .values()
7689            .cloned()
7690            .collect::<Vec<_>>();
7691        let result = session_registry::save_v10(
7692            self.state_path.as_deref(),
7693            &self.node_id,
7694            &workspaces,
7695            &records,
7696            &managed.records(),
7697            &managed.tombstones(),
7698            &materializations,
7699            &managed_spawn_replays,
7700        );
7701        match &result {
7702            Ok(warning) => self.set_persistence_error(warning.clone()),
7703            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7704        }
7705        result.map(|_| ())
7706    }
7707
7708    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7709        let _mutation_guard = self.mutation_gate.lock().await;
7710        self.begin_shutdown_locked().await
7711    }
7712
7713    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7714        if self.shutdown.swap(true, Ordering::AcqRel) {
7715            return Ok(());
7716        }
7717        SessionLifecycleHook::on_shutdown(self);
7718        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7719        let mut first_error = None;
7720        for session in &self.handle.snapshot().sessions {
7721            let remaining = deadline.saturating_duration_since(Instant::now());
7722            if remaining.is_zero() {
7723                first_error.get_or_insert_with(|| {
7724                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7725                });
7726                break;
7727            }
7728            if let Err(error) = self
7729                .dispatch_bounded(
7730                    ControlCommand::Stop {
7731                        instance_id: session.instance_id,
7732                        force: true,
7733                    },
7734                    remaining,
7735                )
7736                .await
7737            {
7738                first_error.get_or_insert(error);
7739            }
7740        }
7741        self.shutdown_notify.notify_waiters();
7742        match first_error {
7743            Some(error) => Err(error),
7744            None => Ok(()),
7745        }
7746    }
7747
7748    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7749        let bindings = self
7750            .session_bindings
7751            .lock()
7752            .unwrap_or_else(|poisoned| poisoned.into_inner());
7753        if bindings.len() >= CONTROL_SESSIONS_MAX {
7754            Err(failure(
7755                NodeFailureCode::BackendBusy,
7756                "node session-binding capacity is full; remove an existing session first",
7757            ))
7758        } else {
7759            Ok(())
7760        }
7761    }
7762
7763    /// One-owner workspace/env collision guard: a live session on
7764    /// `workspace_id` with a different `environment_profile` (option
7765    /// equality on the resolved receipt, including Some vs None) blocks
7766    /// another spawn on that same cwd. Same profile may still share the
7767    /// workspace; distinct profiles need distinct workspace ids (managed
7768    /// worktree).
7769    fn ensure_workspace_environment_profile_compatible(
7770        &self,
7771        workspace_id: &WorkspaceId,
7772        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7773    ) -> Result<(), NodeFailure> {
7774        let bindings = self
7775            .session_bindings
7776            .lock()
7777            .unwrap_or_else(|poisoned| poisoned.into_inner());
7778        for binding in bindings.values() {
7779            if &binding.workspace_id != workspace_id {
7780                continue;
7781            }
7782            if binding.environment_profile.as_ref() != environment_profile {
7783                return Err(failure(
7784                    NodeFailureCode::WorkspaceBusy,
7785                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7786                ));
7787            }
7788        }
7789        Ok(())
7790    }
7791
7792    #[cfg(test)]
7793    fn bind_session(
7794        &self,
7795        instance_id: AgentInstanceId,
7796        workspace_id: WorkspaceId,
7797        generation: SessionGeneration,
7798    ) {
7799        self.bind_session_with_policy(
7800            &SessionAddress {
7801                workspace_id,
7802                session: SessionKey { instance_id, generation },
7803            },
7804            ProviderRuntimePolicy::raw_pty(),
7805            None,
7806        );
7807    }
7808
7809    #[cfg(test)]
7810    fn bind_session_with_policy(
7811        &self,
7812        address: &SessionAddress,
7813        runtime_policy: ProviderRuntimePolicy,
7814        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7815    ) {
7816        self.bind_session_with_materialization(
7817            address,
7818            runtime_policy,
7819            environment_profile,
7820            None,
7821            None,
7822            None,
7823        );
7824    }
7825
7826    fn bind_session_with_materialization(
7827        &self,
7828        address: &SessionAddress,
7829        runtime_policy: ProviderRuntimePolicy,
7830        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7831        bundle: Option<ResolvedBundleReceipt>,
7832        context: Option<ResolvedContextPackReceipt>,
7833        materialization_id: Option<MaterializationId>,
7834    ) {
7835        let mut bindings = self
7836            .session_bindings
7837            .lock()
7838            .unwrap_or_else(|poisoned| poisoned.into_inner());
7839        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7840        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7841        bindings.insert(address.session.instance_id, SessionBinding {
7842            workspace_id: address.workspace_id.clone(),
7843            generation: address.session.generation,
7844            runtime_policy,
7845            pending_resume: None,
7846            record_id: None,
7847            managed_worktree_lease_id: None,
7848            environment_profile,
7849            bundle,
7850            context,
7851            materialization_id,
7852        });
7853    }
7854
7855    #[cfg(test)]
7856    fn bind_managed_session(
7857        &self,
7858        address: &SessionAddress,
7859        record_id: SessionRecordId,
7860        runtime_policy: ProviderRuntimePolicy,
7861        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7862    ) {
7863        self.bind_managed_session_with_materialization(
7864            address,
7865            record_id,
7866            runtime_policy,
7867            environment_profile,
7868            None,
7869            None,
7870            None,
7871        );
7872    }
7873
7874    fn bind_managed_session_with_materialization(
7875        &self,
7876        address: &SessionAddress,
7877        record_id: SessionRecordId,
7878        runtime_policy: ProviderRuntimePolicy,
7879        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880        bundle: Option<ResolvedBundleReceipt>,
7881        context: Option<ResolvedContextPackReceipt>,
7882        materialization_id: Option<MaterializationId>,
7883    ) {
7884        let mut bindings = self
7885            .session_bindings
7886            .lock()
7887            .unwrap_or_else(|poisoned| poisoned.into_inner());
7888        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7889        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7890        bindings.insert(
7891            address.session.instance_id,
7892            SessionBinding {
7893                workspace_id: address.workspace_id.clone(),
7894                generation: address.session.generation,
7895                runtime_policy,
7896                pending_resume: None,
7897                record_id: Some(record_id),
7898                managed_worktree_lease_id: None,
7899                environment_profile,
7900                bundle,
7901                context,
7902                materialization_id,
7903            },
7904        );
7905    }
7906
7907    #[cfg(test)]
7908    fn bind_spawn_session(
7909        &self,
7910        address: &SessionAddress,
7911        provider: AgentId,
7912        mode: SessionMode,
7913        runtime_policy: ProviderRuntimePolicy,
7914        record_policy: SpawnRecordPolicy,
7915        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7916    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7917        self.bind_spawn_session_with_materialization(
7918            address,
7919            provider,
7920            mode,
7921            runtime_policy,
7922            record_policy,
7923            environment_profile,
7924            None,
7925            None,
7926            None,
7927        )
7928    }
7929
7930    fn bind_spawn_session_with_materialization(
7931        &self,
7932        address: &SessionAddress,
7933        provider: AgentId,
7934        mode: SessionMode,
7935        runtime_policy: ProviderRuntimePolicy,
7936        record_policy: SpawnRecordPolicy,
7937        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7938        bundle: Option<ResolvedBundleReceipt>,
7939        context: Option<ResolvedContextPackReceipt>,
7940        materialization_id: Option<MaterializationId>,
7941    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7942        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7943            && !runtime_policy.provider_session_identity
7944        {
7945            self.bind_session_with_materialization(
7946                address,
7947                runtime_policy,
7948                environment_profile,
7949                bundle,
7950                context,
7951                materialization_id,
7952            );
7953            return Ok(None);
7954        }
7955        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7956        let record = self.new_record(
7957            address,
7958            provider,
7959            mode,
7960            active_record_state(runtime_policy.provider_session_identity, false),
7961            None,
7962            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7963            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7964            record_owns_lifecycle.then(|| context.clone()).flatten(),
7965        )?;
7966        let record_id = record.record_id.clone();
7967        let transaction = self
7968            .state_transaction
7969            .lock()
7970            .unwrap_or_else(|poisoned| poisoned.into_inner());
7971        self.insert_record(record.clone())?;
7972        let previous_materialization = if record_owns_lifecycle {
7973            if let Some(materialization_id) = materialization_id.as_ref() {
7974                let mut materializations = self
7975                    .materializations
7976                    .lock()
7977                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7978                let Some(ownership) = materializations.get_mut(materialization_id) else {
7979                    drop(materializations);
7980                    self.remove_record_memory(&record_id);
7981                    return Err(failure(
7982                        NodeFailureCode::BackendOperationFailed,
7983                        "session environment ownership disappeared before binding",
7984                    ));
7985                };
7986                let previous = ownership.clone();
7987                if ownership
7988                    .transfer_to_record(record_id.clone(), unix_time_ms())
7989                    .is_err()
7990                {
7991                    drop(materializations);
7992                    self.remove_record_memory(&record_id);
7993                    return Err(failure(
7994                        NodeFailureCode::BackendOperationFailed,
7995                        "session environment ownership transfer failed",
7996                    ));
7997                }
7998                Some(previous)
7999            } else {
8000                None
8001            }
8002        } else {
8003            None
8004        };
8005        self.bind_managed_session_with_materialization(
8006            address,
8007            record_id.clone(),
8008            runtime_policy,
8009            environment_profile,
8010            bundle,
8011            context,
8012            materialization_id.clone(),
8013        );
8014        if let Err(error) = self.persist_state_locked() {
8015            self.remove_binding(address);
8016            self.remove_record_memory(&record_id);
8017            if let (Some(materialization_id), Some(previous)) =
8018                (materialization_id.as_ref(), previous_materialization)
8019            {
8020                self.materializations
8021                    .lock()
8022                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8023                    .insert(materialization_id.clone(), previous);
8024            }
8025            return Err(persistence_failure(error));
8026        }
8027        drop(transaction);
8028        self.publish_record(record);
8029        Ok(Some(record_id))
8030    }
8031
8032    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8033        for _ in 0..8 {
8034            let bytes = random_nonce().map_err(|error| {
8035                failure(
8036                    NodeFailureCode::BackendOperationFailed,
8037                    &format!("session record identity generation failed: {error}"),
8038                )
8039            })?;
8040            let mut value = String::with_capacity(27);
8041            value.push_str("sr-");
8042            for byte in &bytes[..12] {
8043                use std::fmt::Write as _;
8044                let _ = write!(&mut value, "{byte:02x}");
8045            }
8046            let record_id = SessionRecordId::new(value).map_err(|error| {
8047                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8048            })?;
8049            let records = self
8050                .session_records
8051                .lock()
8052                .unwrap_or_else(|poisoned| poisoned.into_inner());
8053            if !records.records.contains_key(&record_id) {
8054                return Ok(record_id);
8055            }
8056        }
8057        Err(failure(
8058            NodeFailureCode::BackendBusy,
8059            "could not allocate a unique session record ID",
8060        ))
8061    }
8062
8063    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8064        for _ in 0..8 {
8065            let bytes = random_nonce().map_err(|error| {
8066                failure(
8067                    NodeFailureCode::BackendOperationFailed,
8068                    &format!("task identity generation failed: {error}"),
8069                )
8070            })?;
8071            let mut nonce = [0_u8; 12];
8072            nonce.copy_from_slice(&bytes[..12]);
8073            let task_id = TaskId::from_nonce(nonce);
8074            let records = self
8075                .session_records
8076                .lock()
8077                .unwrap_or_else(|poisoned| poisoned.into_inner());
8078            if !records.records.values().any(|record| {
8079                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8080                    == Some(&task_id)
8081            }) {
8082                return Ok(task_id);
8083            }
8084        }
8085        Err(failure(
8086            NodeFailureCode::BackendBusy,
8087            "could not allocate a unique task ID",
8088        ))
8089    }
8090
8091    fn default_record_name(&self, provider: &AgentId) -> String {
8092        let records = self
8093            .session_records
8094            .lock()
8095            .unwrap_or_else(|poisoned| poisoned.into_inner());
8096        let ordinal = records
8097            .records
8098            .values()
8099            .filter(|record| &record.provider == provider)
8100            .count()
8101            .saturating_add(1);
8102        format!("{} #{ordinal}", provider.as_str())
8103    }
8104
8105    fn new_record(
8106        &self,
8107        address: &SessionAddress,
8108        provider: AgentId,
8109        mode: SessionMode,
8110        state: ManagedSessionState,
8111        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8112        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8113        bundle: Option<ResolvedBundleReceipt>,
8114        context: Option<ResolvedContextPackReceipt>,
8115    ) -> Result<ManagedSessionRecord, NodeFailure> {
8116        let canonical_root = self.workspace_root(&address.workspace_id)?;
8117        let now = unix_time_ms();
8118        Ok(ManagedSessionRecord {
8119            record_id: self.allocate_record_id()?,
8120            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8121            provider,
8122            mode,
8123            state,
8124            workspace_id: address.workspace_id.clone(),
8125            canonical_root: opaque_windows_path(canonical_root),
8126            provider_session,
8127            active_session: Some(address.clone()),
8128            environment_profile,
8129            bundle,
8130            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8131            context,
8132            exported_context: None,
8133            task_binding: None,
8134            created_at_unix_ms: now,
8135            updated_at_unix_ms: now,
8136            last_error: None,
8137        })
8138    }
8139
8140    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8141        let mut records = self
8142            .session_records
8143            .lock()
8144            .unwrap_or_else(|poisoned| poisoned.into_inner());
8145        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8146            return Err(failure(
8147                NodeFailureCode::BackendBusy,
8148                "managed session record capacity is full; forget an inactive record first",
8149            ));
8150        }
8151        if let Some(identity) = record.provider_session.as_ref() {
8152            if records.records.values().any(|candidate| {
8153                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8154                    session_registry::same_provider_session(
8155                        &candidate.provider,
8156                        candidate_identity,
8157                        &record.provider,
8158                        identity,
8159                    )
8160                })
8161            }) {
8162                return Err(failure(
8163                    NodeFailureCode::SessionRecordConflict,
8164                    "provider session is already represented by another managed record",
8165                ));
8166            }
8167        }
8168        match records.records.entry(record.record_id.clone()) {
8169            std::collections::btree_map::Entry::Vacant(entry) => {
8170                entry.insert(record);
8171            }
8172            std::collections::btree_map::Entry::Occupied(_) => {
8173                return Err(failure(
8174                    NodeFailureCode::SessionRecordConflict,
8175                    "session record ID collision",
8176                ));
8177            }
8178        }
8179        Ok(())
8180    }
8181
8182    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8183        self.session_records
8184            .lock()
8185            .unwrap_or_else(|poisoned| poisoned.into_inner())
8186            .records
8187            .get(record_id)
8188            .cloned()
8189            .ok_or_else(|| {
8190                failure(
8191                    NodeFailureCode::UnknownSessionRecord,
8192                    "managed session record does not exist",
8193                )
8194            })
8195    }
8196
8197    fn revalidate_session_record_preview(
8198        &self,
8199        expected: &ManagedSessionRecord,
8200        identity: &ProviderSessionIdentity,
8201    ) -> Result<(), NodeFailure> {
8202        let current = self.record(&expected.record_id)?;
8203        let current_root = self.workspace_root(&current.workspace_id)?;
8204        if current.provider != expected.provider
8205            || current.workspace_id != expected.workspace_id
8206            || current.canonical_root != expected.canonical_root
8207            || current.provider_session.as_ref() != Some(identity)
8208            || !platform::roots_equal(
8209                &current_root,
8210                windows_path_text(&current.canonical_root),
8211            )
8212        {
8213            return Err(failure(
8214                NodeFailureCode::SessionRecordConflict,
8215                "managed session changed while its preview was loading",
8216            ));
8217        }
8218        Ok(())
8219    }
8220
8221    fn revalidate_session_record_context_export(
8222        &self,
8223        expected: &ManagedSessionRecord,
8224        identity: Option<&ProviderSessionIdentity>,
8225        session: &SessionAddress,
8226        allow_clean_detachment: bool,
8227    ) -> Result<(), NodeFailure> {
8228        let current = self.record(&expected.record_id)?;
8229        let current_root = self.workspace_root(&current.workspace_id)?;
8230        let snapshot = self.internal_session_snapshot(session)?;
8231        let binding = self
8232            .session_bindings
8233            .lock()
8234            .unwrap_or_else(|poisoned| poisoned.into_inner())
8235            .get(&session.session.instance_id)
8236            .cloned()
8237            .ok_or_else(|| {
8238                failure(
8239                    NodeFailureCode::SessionRecordConflict,
8240                    "managed session runtime binding disappeared during context export",
8241                )
8242            })?;
8243        if !session_record_context_export_binding_is_exact(
8244            expected,
8245            &current,
8246            identity,
8247            session,
8248            &binding,
8249            &snapshot.agent_id,
8250            &current_root,
8251            allow_clean_detachment,
8252        ) || !session_record_context_export_source_is_usable(
8253            &current,
8254            &snapshot.status,
8255            allow_clean_detachment,
8256        ) {
8257            return Err(failure(
8258                NodeFailureCode::SessionRecordConflict,
8259                "managed session binding changed during context export",
8260            ));
8261        }
8262        Ok(())
8263    }
8264
8265    fn revalidate_loaded_history(
8266        &self,
8267        session: &SessionAddress,
8268        candidate_id: &str,
8269        loaded: &HistorySessionRecord,
8270    ) -> Result<(), NodeFailure> {
8271        let snapshot = self.internal_session_snapshot(session)?;
8272        if snapshot.history.pending.is_some()
8273            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8274            || snapshot.history.loaded.as_ref() != Some(loaded)
8275        {
8276            return Err(failure(
8277                NodeFailureCode::SessionRecordConflict,
8278                "managed session history changed during context export",
8279            ));
8280        }
8281        Ok(())
8282    }
8283
8284    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8285        self.session_records
8286            .lock()
8287            .unwrap_or_else(|poisoned| poisoned.into_inner())
8288            .records
8289            .remove(record_id)
8290    }
8291
8292    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8293        if self.destroy_record_materialization(record_id)? {
8294            self.publish(NodeEvent::SessionRecordRemoved {
8295                record_id: record_id.clone(),
8296            });
8297            return Ok(());
8298        }
8299        let transaction = self
8300            .state_transaction
8301            .lock()
8302            .unwrap_or_else(|poisoned| poisoned.into_inner());
8303        let Some(removed) = self.remove_record_memory(record_id) else {
8304            return Ok(());
8305        };
8306        if let Err(error) = self.persist_state_locked() {
8307            let _ = self.insert_record(removed);
8308            return Err(persistence_failure(error));
8309        }
8310        drop(transaction);
8311        self.publish(NodeEvent::SessionRecordRemoved {
8312            record_id: record_id.clone(),
8313        });
8314        Ok(())
8315    }
8316
8317    fn publish_record(&self, record: ManagedSessionRecord) {
8318        self.publish(NodeEvent::SessionRecordUpserted { record });
8319    }
8320
8321    fn mark_record_error(
8322        &self,
8323        record_id: &SessionRecordId,
8324        message: &str,
8325    ) -> Result<(), NodeFailure> {
8326        let transaction = self
8327            .state_transaction
8328            .lock()
8329            .unwrap_or_else(|poisoned| poisoned.into_inner());
8330        let previous = {
8331            let mut records = self
8332                .session_records
8333                .lock()
8334                .unwrap_or_else(|poisoned| poisoned.into_inner());
8335            let Some(record) = records.records.get_mut(record_id) else {
8336                return Ok(());
8337            };
8338            let previous = record.clone();
8339            record.state = ManagedSessionState::Unavailable;
8340            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8341            record.updated_at_unix_ms = unix_time_ms();
8342            previous
8343        };
8344        if let Err(error) = self.persist_state_locked() {
8345            self.session_records
8346                .lock()
8347                .unwrap_or_else(|poisoned| poisoned.into_inner())
8348                .records
8349                .insert(record_id.clone(), previous);
8350            return Err(persistence_failure(error));
8351        }
8352        let record = self.record(record_id)?;
8353        drop(transaction);
8354        self.publish_record(record);
8355        Ok(())
8356    }
8357
8358    fn rename_session_record(
8359        &self,
8360        record_id: &SessionRecordId,
8361        display_name: String,
8362    ) -> Result<ManagedSessionRecord, NodeFailure> {
8363        validate_display_name(&display_name)
8364            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8365        let _transaction = self
8366            .state_transaction
8367            .lock()
8368            .unwrap_or_else(|poisoned| poisoned.into_inner());
8369        let previous = {
8370            let mut records = self
8371                .session_records
8372                .lock()
8373                .unwrap_or_else(|poisoned| poisoned.into_inner());
8374            let record = records.records.get_mut(record_id).ok_or_else(|| {
8375                failure(
8376                    NodeFailureCode::UnknownSessionRecord,
8377                    "managed session record does not exist",
8378                )
8379            })?;
8380            let previous = record.clone();
8381            record.display_name = display_name;
8382            record.updated_at_unix_ms = unix_time_ms();
8383            previous
8384        };
8385        if let Err(error) = self.persist_state_locked() {
8386            self.session_records
8387                .lock()
8388                .unwrap_or_else(|poisoned| poisoned.into_inner())
8389                .records
8390                .insert(record_id.clone(), previous);
8391            return Err(persistence_failure(error));
8392        }
8393        let updated = self.record(record_id)?;
8394        self.publish_record(updated.clone());
8395        Ok(updated)
8396    }
8397
8398    fn set_session_task(
8399        &self,
8400        record_id: &SessionRecordId,
8401        expected_revision: u64,
8402        target: SessionTaskTargetV1,
8403    ) -> Result<ManagedSessionRecord, NodeFailure> {
8404        let transaction = self
8405            .state_transaction
8406            .lock()
8407            .unwrap_or_else(|poisoned| poisoned.into_inner());
8408        let current = self.record(record_id)?;
8409        let current_task_id = current
8410            .task_binding
8411            .as_ref()
8412            .and_then(|binding| binding.task_id.as_ref());
8413        let current_revision = current
8414            .task_binding
8415            .as_ref()
8416            .map_or(0, |binding| binding.revision);
8417        if expected_revision != current_revision {
8418            return Err(failure(
8419                NodeFailureCode::SessionRecordConflict,
8420                "managed session task binding revision changed",
8421            ));
8422        }
8423        let exact = match &target {
8424            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8425            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8426            SessionTaskTargetV1::New => false,
8427        };
8428        if exact {
8429            return Ok(current);
8430        }
8431        let revision = current_revision.checked_add(1).ok_or_else(|| {
8432            failure(
8433                NodeFailureCode::SessionRecordConflict,
8434                "managed session task binding revision is exhausted",
8435            )
8436        })?;
8437        let task_id = match target {
8438            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8439            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8440            SessionTaskTargetV1::Clear => None,
8441        };
8442        let changed_at_unix_ms = unix_time_ms()
8443            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8444                failure(
8445                    NodeFailureCode::SessionRecordConflict,
8446                    "managed session record timestamp is exhausted",
8447                )
8448            })?);
8449        let updated = {
8450            let mut records = self
8451                .session_records
8452                .lock()
8453                .unwrap_or_else(|poisoned| poisoned.into_inner());
8454            let record = records.records.get_mut(record_id).ok_or_else(|| {
8455                failure(
8456                    NodeFailureCode::UnknownSessionRecord,
8457                    "managed session record does not exist",
8458                )
8459            })?;
8460            record.task_binding = Some(SessionTaskBindingV1 {
8461                revision,
8462                task_id,
8463                changed_at_unix_ms,
8464            });
8465            record.updated_at_unix_ms = changed_at_unix_ms;
8466            record.clone()
8467        };
8468        if let Err(error) = self.persist_state_locked() {
8469            self.session_records
8470                .lock()
8471                .unwrap_or_else(|poisoned| poisoned.into_inner())
8472                .records
8473                .insert(record_id.clone(), current);
8474            return Err(persistence_failure(error));
8475        }
8476        drop(transaction);
8477        self.publish_record(updated.clone());
8478        Ok(updated)
8479    }
8480
8481    fn index_provider_session(
8482        &self,
8483        workspace_id: WorkspaceId,
8484        provider: AgentId,
8485        identity: ProviderSessionIdentity,
8486        display_name: String,
8487    ) -> Result<ManagedSessionRecord, NodeFailure> {
8488        self.index_provider_session_with_policy(
8489            workspace_id,
8490            provider,
8491            identity,
8492            display_name,
8493            false,
8494        )
8495    }
8496
8497    fn index_provider_session_with_policy(
8498        &self,
8499        workspace_id: WorkspaceId,
8500        provider: AgentId,
8501        identity: ProviderSessionIdentity,
8502        display_name: String,
8503        allow_validated_transcript_path: bool,
8504    ) -> Result<ManagedSessionRecord, NodeFailure> {
8505        validate_display_name(&display_name)
8506            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8507        identity
8508            .validate()
8509            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8510        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8511            return Err(failure(
8512                NodeFailureCode::InvalidRequest,
8513                "provider session reference index v1 does not accept transcript paths",
8514            ));
8515        }
8516        let canonical_root = self.workspace_root(&workspace_id)?;
8517        if !self.enabled_providers.contains(&provider) {
8518            return Err(failure(
8519                NodeFailureCode::InvalidRequest,
8520                "provider is not enabled on this node",
8521            ));
8522        }
8523
8524        let transaction = self
8525            .state_transaction
8526            .lock()
8527            .unwrap_or_else(|poisoned| poisoned.into_inner());
8528        {
8529            let records = self
8530                .session_records
8531                .lock()
8532                .unwrap_or_else(|poisoned| poisoned.into_inner());
8533            if let Some(existing) = records.records.values().find(|candidate| {
8534                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8535                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8536                })
8537            }) {
8538                if existing.provider == provider && existing.workspace_id == workspace_id {
8539                    return Ok(existing.clone());
8540                }
8541                return Err(failure(
8542                    NodeFailureCode::SessionRecordConflict,
8543                    "provider session reference conflicts with another provider or workspace",
8544                ));
8545            }
8546            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8547                return Err(failure(
8548                    NodeFailureCode::BackendBusy,
8549                    "managed session record capacity is full; forget an inactive record first",
8550                ));
8551            }
8552        }
8553
8554        let now = unix_time_ms();
8555        let record = ManagedSessionRecord {
8556            record_id: self.allocate_record_id()?,
8557            display_name,
8558            provider,
8559            mode: SessionMode::Pty,
8560            state: ManagedSessionState::Dormant,
8561            workspace_id,
8562            canonical_root: opaque_windows_path(canonical_root),
8563            provider_session: Some(identity),
8564            active_session: None,
8565            environment_profile: None,
8566            bundle: None,
8567            context_id: None,
8568            context: None,
8569            exported_context: None,
8570            task_binding: None,
8571            created_at_unix_ms: now,
8572            updated_at_unix_ms: now,
8573            last_error: None,
8574        };
8575        self.insert_record(record.clone())?;
8576        if let Err(error) = self.persist_state_locked() {
8577            self.remove_record_memory(&record.record_id);
8578            return Err(persistence_failure(error));
8579        }
8580        drop(transaction);
8581        self.publish_record(record.clone());
8582        Ok(record)
8583    }
8584
8585    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8586        self.session_bindings
8587            .lock()
8588            .unwrap_or_else(|poisoned| poisoned.into_inner())
8589            .iter()
8590            .find_map(|(instance_id, binding)| {
8591                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8592                    workspace_id: binding.workspace_id.clone(),
8593                    session: SessionKey {
8594                        instance_id: *instance_id,
8595                        generation: binding.generation,
8596                    },
8597                })
8598            })
8599    }
8600
8601    async fn forget_session_record(
8602        &self,
8603        record_id: &SessionRecordId,
8604    ) -> Result<(), NodeFailure> {
8605        let record = self.record(record_id)?;
8606        if record.active_session.is_some()
8607            || matches!(
8608                record.state,
8609                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8610            )
8611        {
8612            return Err(failure(
8613                NodeFailureCode::SessionRecordBusy,
8614                "stop the managed session before forgetting it",
8615            ));
8616        }
8617        if let Some(address) = self.bound_address_for_record(record_id) {
8618            self.remove_session(&address).await?;
8619        }
8620        self.remove_managed_session_record(record_id, &record.workspace_id).await
8621    }
8622
8623    /// Removes one managed session record from durable state and notifies
8624    /// every listener -- the shared transaction body behind both
8625    /// `forget_session_record`'s operator-driven path (busy-checked above
8626    /// it, and clears any still-live session binding before calling in
8627    /// here) and the retention sweep (`retire_unavailable_session_records`,
8628    /// which only ever selects `ManagedSessionState::Unavailable` records
8629    /// that are already known never-busy and never bound, so it calls
8630    /// straight into this with neither check). Cleans a managed-worktree
8631    /// lease if the record held one, removes the record's environment
8632    /// materialization (or, absent one, removes it from memory directly),
8633    /// persists under `state_transaction` with rollback on persist failure,
8634    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8635    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8636    /// exactly as it does for an operator-driven forget.
8637    async fn remove_managed_session_record(
8638        &self,
8639        record_id: &SessionRecordId,
8640        workspace_id: &WorkspaceId,
8641    ) -> Result<(), NodeFailure> {
8642        let managed_lease_id = self.managed_worktrees.lock()
8643            .unwrap_or_else(|poisoned| poisoned.into_inner())
8644            .lease_for_workspace(workspace_id);
8645        if !self.destroy_record_materialization(record_id)? {
8646            let transaction = self
8647                .state_transaction
8648                .lock()
8649                .unwrap_or_else(|poisoned| poisoned.into_inner());
8650            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8651                failure(
8652                    NodeFailureCode::UnknownSessionRecord,
8653                    "managed session record does not exist",
8654                )
8655            })?;
8656            if let Err(error) = self.persist_state_locked() {
8657                let _ = self.insert_record(removed);
8658                return Err(persistence_failure(error));
8659            }
8660            drop(transaction);
8661        }
8662        self.publish(NodeEvent::SessionRecordRemoved {
8663            record_id: record_id.clone(),
8664        });
8665        if let Some(lease_id) = managed_lease_id {
8666            if let Some(snapshot) = self.managed_worktrees.lock()
8667                .unwrap_or_else(|poisoned| poisoned.into_inner())
8668                .get(&lease_id)
8669                .map(ManagedWorktreeLeaseRecord::snapshot)
8670            {
8671                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8672            }
8673            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8674        }
8675        Ok(())
8676    }
8677
8678    /// Retires `ManagedSessionState::Unavailable` records against the
8679    /// node's configured `session_record_retention` age/keep-N policy.
8680    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8681    /// `Dormant` is resumable and folding it into this sweep would delete
8682    /// records a caller can still act on. Both policy fields default to
8683    /// `0` (disabled); when both are `0` this is a no-op. When both are
8684    /// set, a record is retired only if it fails BOTH tests (older than
8685    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8686    /// workspace); when only one is set, that one alone decides. Called
8687    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8688    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8689    /// loop -- see both call sites.
8690    async fn retire_unavailable_session_records(&self) {
8691        let policy = self.session_record_retention;
8692        let age_enabled = policy.age_ms > 0;
8693        let keep_enabled = policy.keep_per_workspace > 0;
8694        if !age_enabled && !keep_enabled {
8695            return;
8696        }
8697        let now = unix_time_ms();
8698        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8699            self.session_records
8700                .lock()
8701                .unwrap_or_else(|poisoned| poisoned.into_inner())
8702                .records
8703                .values()
8704                .filter(|record| record.state == ManagedSessionState::Unavailable)
8705                .map(|record| {
8706                    (
8707                        record.record_id.clone(),
8708                        record.workspace_id.clone(),
8709                        record.provider.clone(),
8710                        record.updated_at_unix_ms,
8711                    )
8712                })
8713                .collect()
8714        };
8715        if candidates.is_empty() {
8716            return;
8717        }
8718        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8719            BTreeMap::new();
8720        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8721            by_workspace
8722                .entry(workspace_id)
8723                .or_default()
8724                .push((record_id, provider, updated_at_unix_ms));
8725        }
8726        let reason = if age_enabled && keep_enabled {
8727            "age+keep-n"
8728        } else if age_enabled {
8729            "age"
8730        } else {
8731            "keep-n"
8732        };
8733        let mut retired: u64 = 0;
8734        for (workspace_id, mut records) in by_workspace {
8735            // Newest `updated_at_unix_ms` first, so index `>=
8736            // keep_per_workspace` names exactly "beyond the newest N".
8737            records.sort_by(|left, right| right.2.cmp(&left.2));
8738            for (index, (record_id, provider, updated_at_unix_ms)) in
8739                records.into_iter().enumerate()
8740            {
8741                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8742                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8743                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8744                let should_retire = if age_enabled && keep_enabled {
8745                    age_hit && keep_hit
8746                } else {
8747                    age_hit || keep_hit
8748                };
8749                if !should_retire {
8750                    continue;
8751                }
8752                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8753                    Ok(()) => {
8754                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8755                        retired += 1;
8756                        tracing::info!(
8757                            node_id = %self.node_id,
8758                            record_id = %record_id,
8759                            workspace_id = %workspace_id,
8760                            provider = %provider,
8761                            age_ms = record_age_ms,
8762                            reason,
8763                            "managed session record retired"
8764                        );
8765                    }
8766                    Err(error) => tracing::warn!(
8767                        node_id = %self.node_id,
8768                        record_id = %record_id,
8769                        workspace_id = %workspace_id,
8770                        code = ?error.code,
8771                        cause = %error.message,
8772                        "session record retention sweep failed to retire a record"
8773                    ),
8774                }
8775            }
8776        }
8777        if retired > 0 {
8778            let remaining_unavailable = self
8779                .session_records
8780                .lock()
8781                .unwrap_or_else(|poisoned| poisoned.into_inner())
8782                .records
8783                .values()
8784                .filter(|record| record.state == ManagedSessionState::Unavailable)
8785                .count();
8786            tracing::info!(
8787                node_id = %self.node_id,
8788                retired,
8789                remaining_unavailable,
8790                "session record retention sweep completed"
8791            );
8792        }
8793    }
8794
8795    async fn resume_session_record(
8796        &self,
8797        record_id: &SessionRecordId,
8798        terminal_size: gate4agent_types::TerminalSize,
8799        initial_prompt: Option<String>,
8800    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8801        let mut record = self.record(record_id)?;
8802        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8803        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8804        if record.mode == SessionMode::Acp
8805            && record.provider.as_str() == "claude"
8806            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8807        {
8808            record.mode = SessionMode::Pty;
8809            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8810                r.mode = SessionMode::Pty;
8811            }
8812            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8813        }
8814        if record.active_session.is_some()
8815            || matches!(
8816                record.state,
8817                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8818            )
8819        {
8820            return Err(failure(
8821                NodeFailureCode::SessionRecordBusy,
8822                "managed session already has a live runtime binding",
8823            ));
8824        }
8825        let identity = record.provider_session.clone().ok_or_else(|| {
8826            failure(
8827                NodeFailureCode::SessionRecordNotResumable,
8828                "managed session has no verified provider session identity",
8829            )
8830        })?;
8831        let working_directory = self.workspace_root(&record.workspace_id)?;
8832        if !platform::roots_equal(
8833            &working_directory,
8834            windows_path_text(&record.canonical_root),
8835        ) {
8836            return Err(failure(
8837                NodeFailureCode::SessionRecordConflict,
8838                "managed session workspace root changed; refusing to resume in another directory",
8839            ));
8840        }
8841        {
8842            let records = self
8843                .session_records
8844                .lock()
8845                .unwrap_or_else(|poisoned| poisoned.into_inner());
8846            if records.records.values().any(|candidate| {
8847                candidate.record_id != *record_id
8848                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8849                        session_registry::same_provider_session(
8850                            &candidate.provider,
8851                            candidate_identity,
8852                            &record.provider,
8853                            &identity,
8854                        )
8855                    })
8856                    && (candidate.active_session.is_some()
8857                        || matches!(
8858                            candidate.state,
8859                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8860                        ))
8861            }) {
8862                return Err(failure(
8863                    NodeFailureCode::SessionRecordConflict,
8864                    "provider session is already active through another managed record",
8865                ));
8866            }
8867        }
8868        let runtime_requirement = if initial_prompt.is_some() {
8869            ProviderRuntimeRequirement::ResumeWithPrompt
8870        } else {
8871            ProviderRuntimeRequirement::Resume
8872        };
8873        let runtime_policy = self.admit_provider_runtime(
8874            &record.provider,
8875            runtime_requirement,
8876        )
8877        .await?;
8878        // The resumed runtime runs in the session folder its record was named after.
8879        let working_directory = match self.m4a_accounts.as_ref() {
8880            Some(_) => {
8881                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8882                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8883            }
8884            None => working_directory,
8885        };
8886        let request = ResumeLaunchRequest {
8887            working_directory,
8888            terminal_size,
8889            initial_prompt,
8890        };
8891        request
8892            .validate()
8893            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8894        self.ensure_binding_capacity()?;
8895        let resolved_materialization = match self.resolve_record_materialization(
8896            record_id,
8897            &record.provider,
8898            record.mode,
8899            record.environment_profile.as_ref(),
8900            record.bundle.as_ref(),
8901            record.context.as_ref(),
8902        ) {
8903            Ok(materialization) => materialization,
8904            Err(error) => {
8905                let label = match error.code {
8906                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8907                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8908                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8909                    _ => "environment-profile-unavailable",
8910                };
8911                self.mark_record_error(record_id, label)?;
8912                return Err(error);
8913            }
8914        };
8915        let (materialization_id, environment_overlay) = match resolved_materialization {
8916            Some((id, overlay)) => (Some(id), overlay),
8917            None => (None, None),
8918        };
8919        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8920        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8921        let environment_overlay = {
8922            let mut args: Vec<OsString> = Vec::new();
8923            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8924                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8925                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8926                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8927            }
8928            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8929            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8930            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8931                if let Some(sel) = options.as_ref() {
8932                    args.push("--model".into()); args.push(sel.model.clone().into());
8933                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8934                        args.push("--effort".into()); args.push(effort.to_owned().into());
8935                    }
8936                }
8937            }
8938            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::journalled(record.provider.as_str(), record.mode == SessionMode::Acp) {
8939                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8940                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8941                        instance_id: instance_id.0,
8942                        provider: record.provider.as_str().to_owned(),
8943                        workspace_id: record.workspace_id.as_str().to_owned(),
8944                        provider_session_id: identity.id.clone(),
8945                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8946                        cwd: String::new(),
8947                        acp: record.mode == SessionMode::Acp,
8948                        options,
8949                        cols: 0,
8950                        rows: 0,
8951                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8952                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8953                    });
8954                }
8955            }
8956            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8957        };
8958        // The resumed runtime keeps the m4a account of the runtime it replaces.
8959        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8960            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8961                if let Some(old) = self.bound_address_for_record(record_id) {
8962                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8963                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8964                    }
8965                }
8966                let session_key = self.m4a_session_key(instance_id);
8967                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8968            }
8969            _ => environment_overlay,
8970        };
8971        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8972            instance_id,
8973            &record.provider,
8974            record.mode,
8975            record.environment_profile.as_ref(),
8976        ) {
8977            Ok(selection) => selection,
8978            Err(error) => {
8979                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8980                return Err(error);
8981            }
8982        };
8983        let mut instance_overlay = environment_overlay
8984            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8985            .transpose()?
8986            .flatten();
8987        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8988            self.remove_session(&stale_address).await?;
8989        }
8990        let address = SessionAddress {
8991            workspace_id: record.workspace_id.clone(),
8992            session: SessionKey {
8993                instance_id,
8994                generation: SessionGeneration(1),
8995            },
8996        };
8997        let transaction = self
8998            .state_transaction
8999            .lock()
9000            .unwrap_or_else(|poisoned| poisoned.into_inner());
9001        let current = self.record(record_id)?;
9002        if current.active_session.is_some()
9003            || matches!(
9004                current.state,
9005                ManagedSessionState::Live | ManagedSessionState::IdentityPending
9006            )
9007        {
9008            return Err(failure(
9009                NodeFailureCode::SessionRecordBusy,
9010                "managed session became active while resume was being prepared",
9011            ));
9012        }
9013        self.bind_managed_session_with_materialization(
9014            &address,
9015            record_id.clone(),
9016            runtime_policy,
9017            record.environment_profile.clone(),
9018            record.bundle.clone(),
9019            record.context.clone(),
9020            materialization_id,
9021        );
9022        if let Some(selection) = environment_selection.take() {
9023            selection.retain();
9024        }
9025        if let Some(overlay) = instance_overlay.take() {
9026            overlay.retain();
9027        }
9028        drop(transaction);
9029
9030        let transport = match record.mode {
9031            SessionMode::Pty => TransportKind::Pty,
9032            SessionMode::Inline => TransportKind::Pipe,
9033            SessionMode::Acp => TransportKind::Acp,
9034        };
9035        let agent_id = record.provider.clone();
9036        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9037        if let Err(error) = self
9038            .dispatch_bounded(
9039                ControlCommand::Register {
9040                    instance_id,
9041                    agent_id,
9042                    transport,
9043                },
9044                dispatch_timeout,
9045            )
9046            .await
9047        {
9048            if let Some(binding) = self.remove_binding(&address) {
9049                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9050            }
9051            return Err(error);
9052        }
9053        let command = self.prepare_command(ControlCommand::Resume {
9054            instance_id,
9055            target: ResumeTarget::ProviderSession { identity },
9056            runtime_policy,
9057            request,
9058        });
9059        let started_after = self.current_sequence();
9060        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9061            self.rollback_spawn(&address, dispatch_timeout).await?;
9062            return Err(error);
9063        }
9064        if let Err(error) = self.dispatch_envelope(command) {
9065            self.clear_armed_resume(&address);
9066            self.rollback_spawn(&address, dispatch_timeout).await?;
9067            return Err(error);
9068        }
9069        match self
9070            .wait_for_record_resume(&address, started_after)
9071            .await
9072        {
9073            Ok(settled_address) => {
9074                let managed_lease_id = {
9075                    self.managed_worktrees.lock()
9076                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9077                        .lease_for_workspace(&record.workspace_id)
9078                };
9079                if let Some(lease_id) = managed_lease_id {
9080                    {
9081                        let mut bindings = self.session_bindings.lock()
9082                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9083                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9084                            .ok_or_else(|| failure(
9085                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9086                                "resumed managed session lost its exact binding",
9087                            ))?;
9088                        if binding.generation != settled_address.session.generation {
9089                            return Err(failure(
9090                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9091                                "resumed managed session generation diverged",
9092                            ));
9093                        }
9094                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9095                    }
9096                    let snapshot = self.managed_worktrees.lock()
9097                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9098                        .bind_session(
9099                            &lease_id,
9100                            ManagedWorktreeSessionHolder {
9101                                incarnation_id: self.incarnation_id,
9102                                instance_id: settled_address.session.instance_id,
9103                                generation: settled_address.session.generation,
9104                            },
9105                            Some(record_id.clone()),
9106                            unix_time_ms(),
9107                        ).map_err(|message| failure(
9108                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9109                            &message,
9110                        ))?;
9111                    if let Err(error) = self.persist_state() {
9112                        let _ = self.remove_session(&settled_address).await;
9113                        return Err(persistence_failure(error));
9114                    }
9115                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9116                }
9117                Ok((self.record(record_id)?, settled_address))
9118            }
9119            Err(error) => {
9120                if matches!(
9121                    error.code,
9122                    NodeFailureCode::SessionRecordNotResumable
9123                        | NodeFailureCode::BackendOperationFailed
9124                ) {
9125                    if let Some(bound) = self.bound_address_for_record(record_id) {
9126                        let _ = self.remove_session(&bound).await;
9127                    }
9128                }
9129                Err(error)
9130            }
9131        }
9132    }
9133
9134    async fn wait_for_record_resume(
9135        &self,
9136        initial_address: &SessionAddress,
9137        after_sequence: u64,
9138    ) -> Result<SessionAddress, NodeFailure> {
9139        let deadline = Instant::now()
9140            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9141        let mut scan_after = after_sequence;
9142        loop {
9143            let events = self
9144                .history
9145                .lock()
9146                .unwrap_or_else(|poisoned| poisoned.into_inner())
9147                .events
9148                .iter()
9149                .filter(|event| event.sequence > scan_after)
9150                .cloned()
9151                .collect::<Vec<_>>();
9152            for envelope in events {
9153                scan_after = scan_after.max(envelope.sequence);
9154                let NodeEvent::Control { address, event } = envelope.event else {
9155                    continue;
9156                };
9157                if address.session.instance_id != initial_address.session.instance_id {
9158                    continue;
9159                }
9160                match event.event {
9161                    ControlEventKind::Resumed { .. } => return Ok(address),
9162                    ControlEventKind::ResumeDenied { reason } => {
9163                        return Err(failure(
9164                            NodeFailureCode::SessionRecordNotResumable,
9165                            &reason,
9166                        ));
9167                    }
9168                    ControlEventKind::ResumeFailed { message }
9169                    | ControlEventKind::CommandRejected { message } => {
9170                        return Err(failure(
9171                            NodeFailureCode::BackendOperationFailed,
9172                            &message,
9173                        ));
9174                    }
9175                    _ => {}
9176                }
9177            }
9178            if Instant::now() >= deadline {
9179                return Err(failure(
9180                    NodeFailureCode::BackendBusy,
9181                    "managed resume did not settle before the bounded deadline",
9182                ));
9183            }
9184            sleep(Duration::from_millis(5)).await;
9185        }
9186    }
9187
9188    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9189        self.workspaces
9190            .read()
9191            .unwrap_or_else(|poisoned| poisoned.into_inner())
9192            .get(workspace_id)
9193            .cloned()
9194            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9195    }
9196
9197    fn workspace_roots(&self) -> Vec<PathBuf> {
9198        self.workspaces
9199            .read()
9200            .unwrap_or_else(|poisoned| poisoned.into_inner())
9201            .values()
9202            .map(PathBuf::from)
9203            .collect()
9204    }
9205
9206    async fn inspect_workspace(
9207        &self,
9208        workspace_id: WorkspaceId,
9209    ) -> Result<WorkspaceInspection, NodeFailure> {
9210        let _permit = self
9211            .inspection_slots
9212            .clone()
9213            .try_acquire_owned()
9214            .map_err(|_| {
9215                tracing::warn!(
9216                    workspace_id = %workspace_id,
9217                    "workspace inspection rejected: inspection capacity is busy",
9218                );
9219                failure(
9220                    NodeFailureCode::BackendBusy,
9221                    "workspace inspection capacity is busy",
9222                )
9223            })?;
9224        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9225            tracing::warn!(
9226                workspace_id = %workspace_id,
9227                code = ?error.code,
9228                "workspace inspection rejected: workspace is not registered",
9229            );
9230            error
9231        })?;
9232        let tree_root = canonical_root.clone();
9233        let started_at = Instant::now();
9234        let time_budget_ms = workspace_inspection_time_budget_ms();
9235        let entry_cap = workspace_inspection_entry_cap();
9236        let deadline = started_at + Duration::from_millis(time_budget_ms);
9237        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9238            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9239        })
9240        .await
9241        .map_err(|error| {
9242            tracing::warn!(
9243                workspace_id = %workspace_id,
9244                error = %error,
9245                "workspace inspection rejected: walk task failed",
9246            );
9247            failure(
9248                NodeFailureCode::BackendOperationFailed,
9249                &format!("workspace inspection task failed: {error}"),
9250            )
9251        })?;
9252        let (mut git, git_time_budget_exceeded) =
9253            inspect_git_workspace(&canonical_root, deadline).await;
9254        if !git.worktrees.is_empty() {
9255            let registered = self
9256                .workspaces
9257                .read()
9258                .unwrap_or_else(|poisoned| poisoned.into_inner())
9259                .iter()
9260                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9261                .collect::<Vec<_>>();
9262            for worktree in &mut git.worktrees {
9263                worktree.workspace_id = registered
9264                    .iter()
9265                    .find(|(_, root)| {
9266                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9267                    })
9268                    .map(|(workspace_id, _)| workspace_id.clone());
9269            }
9270        }
9271        if git.is_repository {
9272            git.managed_worktree = self
9273                .managed_worktrees
9274                .lock()
9275                .unwrap_or_else(|poisoned| poisoned.into_inner())
9276                .git_scope(&workspace_id, git.branch.as_deref());
9277        }
9278        let truncation = workspace_inspection_truncation(
9279            &walk_budget,
9280            git_time_budget_exceeded,
9281            started_at.elapsed(),
9282        );
9283        if let Some(truncation) = &truncation {
9284            tracing::info!(
9285                workspace_id = %workspace_id,
9286                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9287                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9288                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9289                entries_visited = truncation.entries_visited,
9290                elapsed_ms = truncation.elapsed_ms,
9291                "workspace inspection truncated by its inner budget",
9292            );
9293        }
9294        Ok(WorkspaceInspection {
9295            workspace_id,
9296            entries,
9297            tree_truncated,
9298            git,
9299            truncation,
9300        })
9301    }
9302
9303    async fn browse_host_directories(
9304        &self,
9305        directory: Option<OpaqueHostPath>,
9306        after: Option<OpaqueHostPath>,
9307    ) -> Result<HostDirectoryListing, NodeFailure> {
9308        let permit = self
9309            .inspection_slots
9310            .clone()
9311            .try_acquire_owned()
9312            .map_err(|_| {
9313                failure(
9314                    NodeFailureCode::BackendBusy,
9315                    "host directory browse capacity is busy",
9316                )
9317            })?;
9318        let task = tokio::task::spawn_blocking(move || {
9319            let _permit = permit;
9320            browse_host_directories(directory, after)
9321        });
9322        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9323            .await
9324            .map_err(|_| {
9325                failure(
9326                    NodeFailureCode::HostDirectoryReadTimedOut,
9327                    "host directory browse exceeded its bounded deadline",
9328                )
9329            })?
9330            .map_err(|_| {
9331                failure(
9332                    NodeFailureCode::HostDirectoryReadFailed,
9333                    "host directory browse task failed",
9334                )
9335            })?
9336            .map_err(host_directory_failure)
9337    }
9338
9339    async fn read_workspace_file(
9340        &self,
9341        workspace_id: WorkspaceId,
9342        path: RepositoryPath,
9343    ) -> Result<WorkspaceFileRead, NodeFailure> {
9344        let canonical_root = self.workspace_root(&workspace_id)?;
9345        let permit = self
9346            .inspection_slots
9347            .clone()
9348            .try_acquire_owned()
9349            .map_err(|_| {
9350                failure(
9351                    NodeFailureCode::BackendBusy,
9352                    "workspace file read capacity is busy",
9353                )
9354            })?;
9355        let path_to_read = path.clone();
9356        let task = tokio::task::spawn_blocking(move || {
9357            let _permit = permit;
9358            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9359        });
9360        let content = timeout(
9361            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9362            task,
9363        )
9364        .await
9365        .map_err(|_| {
9366            failure(
9367                NodeFailureCode::RepositoryFileReadTimedOut,
9368                "repository file read exceeded its bounded deadline",
9369            )
9370        })?
9371        .map_err(|_| {
9372            failure(
9373                NodeFailureCode::RepositoryFileReadFailed,
9374                "repository file read task failed",
9375            )
9376        })?
9377        .map_err(workspace_file_failure)?;
9378        let content = match content {
9379            WorkspaceFileBytes::Utf8(text) => {
9380                let revision = Some(workspace_file_revision(text.as_bytes()));
9381                return Ok(WorkspaceFileRead {
9382                    workspace_id,
9383                    path,
9384                    content: WorkspaceFileContent::Utf8 {
9385                        byte_len: u32::try_from(text.len())
9386                            .expect("bounded workspace text length must fit u32"),
9387                        text,
9388                    },
9389                    revision,
9390                });
9391            }
9392            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9393                WorkspaceFileContent::NonUtf8 {
9394                    byte_len: u32::try_from(byte_length)
9395                        .expect("bounded workspace file length must fit u32"),
9396                }
9397            }
9398            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9399                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9400            },
9401        };
9402        Ok(WorkspaceFileRead {
9403            workspace_id,
9404            path,
9405            content,
9406            revision: None,
9407        })
9408    }
9409
9410    async fn write_workspace_file(
9411        &self,
9412        workspace_id: WorkspaceId,
9413        path: RepositoryPath,
9414        expected_revision: WorkspaceFileRevision,
9415        text: String,
9416    ) -> Result<WorkspaceFileRead, NodeFailure> {
9417        let canonical_root = self.workspace_root(&workspace_id)?;
9418        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9420        })?;
9421        let path_to_write = path.clone();
9422        let expected = expected_revision.as_str().to_owned();
9423        let text_to_write = text.clone();
9424        let task = tokio::task::spawn_blocking(move || {
9425            let _permit = permit;
9426            write_workspace_file_to_disk(
9427                Path::new(&canonical_root),
9428                &path_to_write,
9429                &expected,
9430                &text_to_write,
9431            )
9432        });
9433        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9434            .await
9435            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9436            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9437            .map_err(workspace_file_write_failure)?;
9438        Ok(WorkspaceFileRead {
9439            workspace_id,
9440            path,
9441            content: WorkspaceFileContent::Utf8 {
9442                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9443                text,
9444            },
9445            revision: Some(WorkspaceFileRevision::new(revision)
9446                .expect("workspace writer returns a valid SHA-256 revision")),
9447        })
9448    }
9449
9450    async fn create_workspace_file(
9451        &self,
9452        workspace_id: WorkspaceId,
9453        path: RepositoryPath,
9454    ) -> Result<WorkspaceFileRead, NodeFailure> {
9455        let canonical_root = self.workspace_root(&workspace_id)?;
9456        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9457            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9458        })?;
9459        let path_to_create = path.clone();
9460        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9461        let task_commit_state = Arc::clone(&commit_state);
9462        let task = tokio::task::spawn_blocking(move || {
9463            let _permit = permit;
9464            create_workspace_file_on_disk(
9465                Path::new(&canonical_root),
9466                &path_to_create,
9467                &task_commit_state,
9468            )
9469        });
9470        let revision = settle_workspace_entry_create(
9471            task,
9472            commit_state,
9473            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9474            "repository file create exceeded its bounded deadline",
9475            "repository file create task failed",
9476        )
9477        .await?;
9478        Ok(WorkspaceFileRead {
9479            workspace_id,
9480            path,
9481            content: WorkspaceFileContent::Utf8 {
9482                text: String::new(),
9483                byte_len: 0,
9484            },
9485            revision: Some(WorkspaceFileRevision::new(revision)
9486                .expect("workspace creator returns a valid SHA-256 revision")),
9487        })
9488    }
9489
9490    async fn create_workspace_directory(
9491        &self,
9492        workspace_id: WorkspaceId,
9493        path: RepositoryPath,
9494    ) -> Result<WorkspaceEntry, NodeFailure> {
9495        let canonical_root = self.workspace_root(&workspace_id)?;
9496        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9497            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9498        })?;
9499        let path_to_create = path.clone();
9500        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9501        let task_commit_state = Arc::clone(&commit_state);
9502        let task = tokio::task::spawn_blocking(move || {
9503            let _permit = permit;
9504            create_workspace_directory_on_disk(
9505                Path::new(&canonical_root),
9506                &path_to_create,
9507                &task_commit_state,
9508            )
9509        });
9510        settle_workspace_entry_create(
9511            task,
9512            commit_state,
9513            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9514            "repository directory create exceeded its bounded deadline",
9515            "repository directory create task failed",
9516        )
9517        .await?;
9518        Ok(WorkspaceEntry {
9519            relative_path: path,
9520            kind: WorkspaceEntryKind::Directory,
9521        })
9522    }
9523
9524    async fn read_git_history(
9525        &self,
9526        workspace_id: WorkspaceId,
9527        path: Option<RepositoryPath>,
9528        before: Option<GitObjectId>,
9529        limit: u16,
9530    ) -> Result<GitHistoryPage, NodeFailure> {
9531        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9532            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9533        })?;
9534        let root = self.workspace_root(&workspace_id)?;
9535        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9536    }
9537
9538    async fn read_git_diff(
9539        &self,
9540        workspace_id: WorkspaceId,
9541        request: GitDiffRequest,
9542    ) -> Result<GitDiff, NodeFailure> {
9543        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9544            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9545        })?;
9546        let root = self.workspace_root(&workspace_id)?;
9547        read_git_diff_bounded(&root, request).await
9548    }
9549
9550    fn native_session_route_root(
9551        &self,
9552        route: &NativeSessionCatalogRoute,
9553    ) -> Result<Option<String>, NodeFailure> {
9554        route
9555            .validate()
9556            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9557        match route.scope {
9558            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9559                .workspace_id
9560                .as_ref()
9561                .map(|workspace_id| self.workspace_root(workspace_id))
9562                .transpose(),
9563            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9564        }
9565    }
9566
9567    fn project_native_session_entries(
9568        &self,
9569        route: &NativeSessionCatalogRoute,
9570        entries: Vec<ScopedNativeSessionCatalogEntry>,
9571    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9572        let current_root = route
9573            .workspace_id
9574            .as_ref()
9575            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9576        let records = self
9577            .session_records
9578            .lock()
9579            .unwrap_or_else(|poisoned| poisoned.into_inner());
9580        let mut seen_records = std::collections::HashSet::new();
9581        entries
9582            .into_iter()
9583            .map(|entry| {
9584                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9585                    let matches = records
9586                        .records
9587                        .values()
9588                        .filter(|record| {
9589                            record.provider == route.provider
9590                                && record.workspace_id == *workspace_id
9591                                && current_root.as_deref().is_some_and(|root| {
9592                                    platform::roots_equal(
9593                                        root,
9594                                        windows_path_text(&record.canonical_root),
9595                                    )
9596                                })
9597                                && record.provider_session.as_ref().is_some_and(|identity| {
9598                                    session_registry::same_provider_session(
9599                                        &record.provider,
9600                                        identity,
9601                                        &route.provider,
9602                                        &entry.provider_session,
9603                                    )
9604                                })
9605                        })
9606                        .map(|record| record.record_id.clone())
9607                        .collect::<Vec<_>>();
9608                    (matches.len() == 1
9609                        && seen_records.insert(matches[0].clone()))
9610                        .then(|| matches[0].clone())
9611                });
9612                let metadata = entry.metadata;
9613                crate::protocol::NativeSessionCatalogEntry {
9614                    selection_id: metadata.selection_id,
9615                    title: metadata.title,
9616                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9617                    model: metadata.model,
9618                    message_count: metadata.message_count,
9619                    completed_turn_count: metadata.completed_turn_count,
9620                    external_group: entry.external_group,
9621                    record_id,
9622                }
9623            })
9624            .collect()
9625    }
9626
9627    async fn catalog_native_sessions(
9628        &self,
9629        route: NativeSessionCatalogRoute,
9630        limit: u16,
9631    ) -> Result<(
9632        Vec<crate::protocol::NativeSessionCatalogEntry>,
9633        crate::protocol::NativeSessionCatalogSummary,
9634    ), NodeFailure> {
9635        let canonical_root = self.native_session_route_root(&route)?;
9636        let registered_workspace_roots = self.workspace_roots();
9637        let provider = route.provider.clone();
9638        let scope = route.scope;
9639        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9640            failure(
9641                NodeFailureCode::InvalidRequest,
9642                "native session catalog is not configured",
9643            )
9644        })?;
9645        let _permit = Arc::clone(&self.inspection_slots)
9646            .try_acquire_owned()
9647            .map_err(|_| {
9648                failure(
9649                    NodeFailureCode::BackendBusy,
9650                    "native session catalog capacity is busy",
9651                )
9652            })?;
9653        let page = run_native_session_catalog_operation(
9654            catalog,
9655            "native session catalog",
9656            move |catalog| {
9657                catalog.catalog_initial_for_scope(
9658                    &provider,
9659                    scope,
9660                    canonical_root.as_deref().map(Path::new),
9661                    &registered_workspace_roots,
9662                    limit,
9663                )
9664            },
9665        )
9666        .await?;
9667        let summary = crate::protocol::NativeSessionCatalogSummary {
9668            catalog_revision: page.revision,
9669            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9670            recent_total_count: page.recent_total_count,
9671            older_total_count: page.older_total_count,
9672            recent_next_after_selection_id: page.next_after_selection_id,
9673            recent_has_more: page.remaining_count > 0,
9674        };
9675        Ok((self.project_native_session_entries(&route, page.entries), summary))
9676    }
9677
9678    async fn page_native_sessions(
9679        &self,
9680        route: NativeSessionCatalogRoute,
9681        window: crate::protocol::NativeSessionCatalogWindow,
9682        catalog_revision: u64,
9683        recent_cutoff_unix_ms: u64,
9684        after_selection_id: Option<String>,
9685        limit: u16,
9686    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9687        let canonical_root = self.native_session_route_root(&route)?;
9688        let registered_workspace_roots = self.workspace_roots();
9689        let provider = route.provider.clone();
9690        let scope = route.scope;
9691        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9692            failure(
9693                NodeFailureCode::InvalidRequest,
9694                "native session catalog is not configured",
9695            )
9696        })?;
9697        let _permit = Arc::clone(&self.inspection_slots)
9698            .try_acquire_owned()
9699            .map_err(|_| {
9700                failure(
9701                    NodeFailureCode::BackendBusy,
9702                    "native session catalog capacity is busy",
9703                )
9704            })?;
9705        let page = run_native_session_catalog_operation(
9706            catalog,
9707            "native session catalog page",
9708            move |catalog| {
9709                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9710                    &provider,
9711                    scope,
9712                    canonical_root.as_deref().map(Path::new),
9713                    &registered_workspace_roots,
9714                    window,
9715                    catalog_revision,
9716                    recent_cutoff_unix_ms,
9717                    after_selection_id.as_deref(),
9718                    limit,
9719                ))
9720            },
9721        )
9722        .await?;
9723        let page = page.map_err(|error| match error {
9724            NativeSessionCatalogError::StaleCatalog => failure(
9725                NodeFailureCode::StaleNativeSessionCatalog,
9726                "native session catalog revision or cursor is stale",
9727            ),
9728            _ => failure(
9729                NodeFailureCode::BackendOperationFailed,
9730                "native session catalog page failed",
9731            ),
9732        })?;
9733        let entries = self.project_native_session_entries(&route, page.entries);
9734        Ok(crate::protocol::NativeSessionCatalogPage {
9735            window: page.window,
9736            revision: page.revision,
9737            entries,
9738            next_after_selection_id: page.next_after_selection_id,
9739            remaining_count: page.remaining_count,
9740            has_more: page.remaining_count > 0,
9741        })
9742    }
9743
9744    async fn preview_native_session(
9745        &self,
9746        selection: NativeSessionSelection,
9747        message_limit: u16,
9748    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9749        selection
9750            .validate()
9751            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9752        let canonical_root = self.native_session_route_root(&selection.route)?;
9753        let registered_workspace_roots = self.workspace_roots();
9754        let provider = selection.route.provider.clone();
9755        let scope = selection.route.scope;
9756        let catalog_revision = selection.catalog_revision;
9757        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9758        let selection_id = selection.selection_id.clone();
9759        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9760            failure(
9761                NodeFailureCode::InvalidRequest,
9762                "native session preview is not configured",
9763            )
9764        })?;
9765        let _permit = Arc::clone(&self.inspection_slots)
9766            .try_acquire_owned()
9767            .map_err(|_| {
9768                failure(
9769                    NodeFailureCode::BackendBusy,
9770                    "native session preview capacity is busy",
9771                )
9772            })?;
9773        let preview = run_native_session_catalog_operation(
9774            catalog,
9775            "native session preview",
9776            move |catalog| {
9777                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9778                    &provider,
9779                    scope,
9780                    canonical_root.as_deref().map(Path::new),
9781                    &registered_workspace_roots,
9782                    catalog_revision,
9783                    recent_cutoff_unix_ms,
9784                    &selection_id,
9785                    message_limit,
9786                ))
9787            },
9788        )
9789        .await?;
9790        preview
9791            .map(Into::into)
9792            .map_err(native_session_preview_failure)
9793    }
9794
9795    async fn index_native_session(
9796        &self,
9797        selection: NativeSessionSelection,
9798        display_name: String,
9799    ) -> Result<ManagedSessionRecord, NodeFailure> {
9800        selection
9801            .validate()
9802            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9803        if selection.route.scope
9804            != gate4agent_types::NativeSessionCatalogScope::Workspace
9805        {
9806            return Err(failure(
9807                NodeFailureCode::WorkspaceRegistrationRequired,
9808                "register the external project as a workspace before indexing or resuming it",
9809            ));
9810        }
9811        let workspace_id = selection
9812            .route
9813            .workspace_id
9814            .clone()
9815            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9816        let canonical_root = self
9817            .native_session_route_root(&selection.route)?
9818            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9819        let registered_workspace_roots = self.workspace_roots();
9820        let provider = selection.route.provider.clone();
9821        let catalog_revision = selection.catalog_revision;
9822        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9823        let selection_id = selection.selection_id.clone();
9824        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9825            failure(
9826                NodeFailureCode::InvalidRequest,
9827                "native session index is not configured",
9828            )
9829        })?;
9830        let _permit = Arc::clone(&self.inspection_slots)
9831            .try_acquire_owned()
9832            .map_err(|_| {
9833                failure(
9834                    NodeFailureCode::BackendBusy,
9835                    "native session index capacity is busy",
9836                )
9837            })?;
9838        let resolved = run_native_session_catalog_operation(
9839            catalog,
9840            "native session index resolution",
9841            move |catalog| {
9842                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9843                    &provider,
9844                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9845                    Some(Path::new(&canonical_root)),
9846                    &registered_workspace_roots,
9847                    catalog_revision,
9848                    recent_cutoff_unix_ms,
9849                    &selection_id,
9850                ))
9851            },
9852        )
9853        .await?
9854        .map_err(native_session_preview_failure)?;
9855        self.index_provider_session_with_policy(
9856            workspace_id,
9857            selection.route.provider,
9858            resolved.identity,
9859            display_name,
9860            true,
9861        )
9862    }
9863
9864    async fn preview_native_session_by_session_id(
9865        &self,
9866        workspace_id: WorkspaceId,
9867        provider: AgentId,
9868        session_id: String,
9869        message_limit: u16,
9870    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9871        let canonical_root = self.workspace_root(&workspace_id)?;
9872        let registered_workspace_roots = self.workspace_roots();
9873        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9874            failure(
9875                NodeFailureCode::InvalidRequest,
9876                "native session preview is not configured",
9877            )
9878        })?;
9879        let _permit = Arc::clone(&self.inspection_slots)
9880            .try_acquire_owned()
9881            .map_err(|_| {
9882                failure(
9883                    NodeFailureCode::BackendBusy,
9884                    "native session preview capacity is busy",
9885                )
9886            })?;
9887        run_native_session_catalog_operation(
9888            catalog,
9889            "native session preview",
9890            move |catalog| {
9891                catalog.preview_session_id_for_workspace(
9892                    &provider,
9893                    Path::new(&canonical_root),
9894                    &registered_workspace_roots,
9895                    &session_id,
9896                    message_limit,
9897                )
9898            },
9899        )
9900        .await
9901    }
9902
9903    async fn register_workspace(
9904        &self,
9905        workspace_id: WorkspaceId,
9906        root: String,
9907    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9908        validate_workspace_request_root(&root)?;
9909        let canonicalize_workspace_id = workspace_id.clone();
9910        let workspace = tokio::task::spawn_blocking(move || {
9911            WorkspaceConfig::new(canonicalize_workspace_id, root)
9912        })
9913        .await
9914        .map_err(|error| {
9915            failure(
9916                NodeFailureCode::BackendOperationFailed,
9917                &format!("workspace canonicalization task failed: {error}"),
9918            )
9919        })?
9920        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9921        let transaction = self
9922            .state_transaction
9923            .lock()
9924            .unwrap_or_else(|poisoned| poisoned.into_inner());
9925        let mut workspaces = self
9926            .workspaces
9927            .write()
9928            .unwrap_or_else(|poisoned| poisoned.into_inner());
9929        if workspaces.contains_key(&workspace_id) {
9930            return Err(failure(
9931                NodeFailureCode::DuplicateWorkspaceId,
9932                "workspace ID is already registered",
9933            ));
9934        }
9935        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9936            platform::roots_equal(existing_root, workspace.canonical_root())
9937        }) {
9938            return Err(failure(
9939                NodeFailureCode::DuplicateWorkspaceRoot,
9940                &format!("workspace root is already registered as '{existing_id}'"),
9941            ));
9942        }
9943        {
9944            let records = self
9945                .session_records
9946                .lock()
9947                .unwrap_or_else(|poisoned| poisoned.into_inner());
9948            if let Some(record) = records.records.values().find(|record| {
9949                record.workspace_id == workspace_id
9950                    && !record
9951                        .canonical_root
9952                        .as_utf8()
9953                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9954            }) {
9955                return Err(failure(
9956                    NodeFailureCode::SessionRecordConflict,
9957                    &format!(
9958                        "workspace ID is retained by session record '{}' at another root",
9959                        record.record_id,
9960                    ),
9961                ));
9962            }
9963        }
9964        let snapshot = WorkspaceSnapshot {
9965            workspace_id: workspace_id.clone(),
9966            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9967            sessions: Vec::new(),
9968            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9969            managed_worktree_profiles: Some(WorktreeProfileInventory {
9970                profiles: Vec::new(),
9971            }),
9972        };
9973        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9974        drop(workspaces);
9975        let previous_records = {
9976            let mut records = self
9977                .session_records
9978                .lock()
9979                .unwrap_or_else(|poisoned| poisoned.into_inner());
9980            let previous = records.records.clone();
9981            for record in records.records.values_mut().filter(|record| {
9982                record.workspace_id == workspace_id
9983                    && record
9984                        .canonical_root
9985                        .as_utf8()
9986                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9987            }) {
9988                if record.active_session.is_none() {
9989                    record.state = detached_record_state(record);
9990                }
9991                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9992                    record.last_error = None;
9993                }
9994                record.updated_at_unix_ms = unix_time_ms();
9995            }
9996            previous
9997        };
9998        if let Err(error) = self.persist_state_locked() {
9999            self.workspaces
10000                .write()
10001                .unwrap_or_else(|poisoned| poisoned.into_inner())
10002                .remove(&workspace_id);
10003            self.session_records
10004                .lock()
10005                .unwrap_or_else(|poisoned| poisoned.into_inner())
10006                .records = previous_records;
10007            return Err(persistence_failure(error));
10008        }
10009        let restored = self
10010            .session_records
10011            .lock()
10012            .unwrap_or_else(|poisoned| poisoned.into_inner())
10013            .records
10014            .values()
10015            .filter(|record| record.workspace_id == workspace_id)
10016            .cloned()
10017            .collect::<Vec<_>>();
10018        drop(transaction);
10019        self.publish(NodeEvent::WorkspaceAdded {
10020            workspace: snapshot.clone(),
10021        });
10022        for record in restored {
10023            self.publish_record(record);
10024        }
10025        Ok(snapshot)
10026    }
10027
10028    async fn create_standalone_workspace(
10029        &self,
10030        workspace_id: WorkspaceId,
10031        root: String,
10032        initial_branch: Option<String>,
10033    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10034        validate_workspace_request_root(&root)?;
10035        validate_git_revision("initial branch", initial_branch.as_deref())?;
10036        let preflight_workspace_id = workspace_id.clone();
10037        let preflight_root = root.clone();
10038        let canonical_candidate = tokio::task::spawn_blocking(move || {
10039            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10040        })
10041        .await
10042        .map_err(|_| failure(
10043            NodeFailureCode::BackendOperationFailed,
10044            "standalone workspace root preflight task failed",
10045        ))?
10046        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10047        {
10048            let workspaces = self
10049                .workspaces
10050                .read()
10051                .unwrap_or_else(|poisoned| poisoned.into_inner());
10052            if workspaces.contains_key(&workspace_id) {
10053                return Err(failure(
10054                    NodeFailureCode::DuplicateWorkspaceId,
10055                    "workspace ID is already registered",
10056                ));
10057            }
10058            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10059                platform::roots_equal(existing_root, &canonical_candidate)
10060            }) {
10061                return Err(failure(
10062                    NodeFailureCode::DuplicateWorkspaceRoot,
10063                    &format!("workspace root is already registered as '{existing_id}'"),
10064                ));
10065            }
10066        }
10067        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10068            .await
10069            .map_err(standalone_workspace_failure)?;
10070        prepared
10071            .verify_for_registration()
10072            .await
10073            .map_err(standalone_workspace_failure)?;
10074        match self
10075            .register_workspace(workspace_id, prepared.root().to_owned())
10076            .await
10077        {
10078            Ok(workspace) => Ok(workspace),
10079            Err(registration_error) => {
10080                if prepared.compensate_registration_failure().await {
10081                    Err(registration_error)
10082                } else {
10083                    Err(failure(
10084                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10085                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10086                    ))
10087                }
10088            }
10089        }
10090    }
10091
10092    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10093        let bindings = self
10094            .session_bindings
10095            .lock()
10096            .unwrap_or_else(|poisoned| poisoned.into_inner());
10097        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10098            return Err(failure(
10099                NodeFailureCode::WorkspaceBusy,
10100                "workspace retains a bound session; remove every session before unregistering it",
10101            ));
10102        }
10103        drop(bindings);
10104        let transaction = self
10105            .state_transaction
10106            .lock()
10107            .unwrap_or_else(|poisoned| poisoned.into_inner());
10108        let mut workspaces = self
10109            .workspaces
10110            .write()
10111            .unwrap_or_else(|poisoned| poisoned.into_inner());
10112        if !workspaces.contains_key(workspace_id) {
10113            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10114        }
10115        if workspaces.len() == 1 {
10116            return Err(failure(
10117                NodeFailureCode::LastWorkspace,
10118                "node must retain at least one workspace",
10119            ));
10120        }
10121        let removed_root = workspaces
10122            .remove(workspace_id)
10123            .expect("workspace presence was validated");
10124        drop(workspaces);
10125        let previous_records = {
10126            let mut records = self
10127                .session_records
10128                .lock()
10129                .unwrap_or_else(|poisoned| poisoned.into_inner());
10130            let previous = records.records.clone();
10131            for record in records
10132                .records
10133                .values_mut()
10134                .filter(|record| &record.workspace_id == workspace_id)
10135            {
10136                record.active_session = None;
10137                record.state = ManagedSessionState::Unavailable;
10138                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10139                record.updated_at_unix_ms = unix_time_ms();
10140            }
10141            previous
10142        };
10143        if let Err(error) = self.persist_state_locked() {
10144            self.workspaces
10145                .write()
10146                .unwrap_or_else(|poisoned| poisoned.into_inner())
10147                .insert(workspace_id.clone(), removed_root);
10148            self.session_records
10149                .lock()
10150                .unwrap_or_else(|poisoned| poisoned.into_inner())
10151                .records = previous_records;
10152            return Err(persistence_failure(error));
10153        }
10154        let unavailable = self
10155            .session_records
10156            .lock()
10157            .unwrap_or_else(|poisoned| poisoned.into_inner())
10158            .records
10159            .values()
10160            .filter(|record| &record.workspace_id == workspace_id)
10161            .cloned()
10162            .collect::<Vec<_>>();
10163        drop(transaction);
10164        self.publish(NodeEvent::WorkspaceRemoved {
10165            workspace_id: workspace_id.clone(),
10166        });
10167        for record in unavailable {
10168            self.publish_record(record);
10169        }
10170        Ok(())
10171    }
10172
10173    async fn create_worktree(
10174        &self,
10175        source_workspace_id: WorkspaceId,
10176        workspace_id: WorkspaceId,
10177        target_root: String,
10178        branch: String,
10179        base: Option<String>,
10180    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10181        self.require_worktree_service(&source_workspace_id)?;
10182        validate_workspace_request_root(&target_root)?;
10183        validate_git_revision("worktree base", base.as_deref())?;
10184        let source_root = self.workspace_root(&source_workspace_id)?;
10185        {
10186            let workspaces = self
10187                .workspaces
10188                .read()
10189                .unwrap_or_else(|poisoned| poisoned.into_inner());
10190            if workspaces.contains_key(&workspace_id) {
10191                return Err(failure(
10192                    NodeFailureCode::DuplicateWorkspaceId,
10193                    "workspace ID is already registered",
10194                ));
10195            }
10196            if let Some((existing_id, _)) = workspaces
10197                .iter()
10198                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10199            {
10200                return Err(failure(
10201                    NodeFailureCode::DuplicateWorkspaceRoot,
10202                    &format!("workspace root is already registered as '{existing_id}'"),
10203                ));
10204            }
10205        }
10206        let mut worktree = create_git_worktree(
10207            &source_root,
10208            &target_root,
10209            &branch,
10210            base.as_deref(),
10211        )
10212        .await
10213        .map_err(git_worktree_failure)?;
10214        let workspace = match self
10215            .register_workspace(workspace_id, worktree.path.clone())
10216            .await
10217        {
10218            Ok(workspace) => workspace,
10219            Err(registration_error) => {
10220                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10221                    return Err(failure(
10222                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10223                        "worktree registration failed and compensation requires recovery",
10224                    ));
10225                }
10226                return Err(failure(
10227                    registration_error.code,
10228                    &registration_error.message,
10229                ));
10230            }
10231        };
10232        worktree.workspace_id = Some(workspace.workspace_id.clone());
10233        Ok((protocol_worktree(worktree), workspace))
10234    }
10235
10236    async fn remove_worktree(
10237        &self,
10238        source_workspace_id: WorkspaceId,
10239        target_root: String,
10240    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10241        self.require_worktree_service(&source_workspace_id)?;
10242        validate_workspace_request_root(&target_root)?;
10243        let source_root = self.workspace_root(&source_workspace_id)?;
10244        let target_root = normalize_worktree_removal_target(&target_root)
10245            .map_err(git_worktree_failure)?;
10246        let target = list_git_worktrees(&source_root)
10247            .await
10248            .map_err(git_worktree_failure)?
10249            .into_iter()
10250            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10251            .ok_or_else(|| {
10252                failure(
10253                    NodeFailureCode::WorktreeProtected,
10254                    "refusing to remove a path that is not in Git's worktree listing",
10255                )
10256            })?;
10257        let registered_workspace_id = {
10258            let workspaces = self
10259                .workspaces
10260                .read()
10261                .unwrap_or_else(|poisoned| poisoned.into_inner());
10262            let matched = workspaces
10263                .iter()
10264                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10265                .map(|(workspace_id, _)| workspace_id.clone());
10266            if matched.is_some() && workspaces.len() == 1 {
10267                return Err(failure(
10268                    NodeFailureCode::LastWorkspace,
10269                    "node must retain at least one workspace",
10270                ));
10271            }
10272            matched
10273        };
10274        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10275            let bindings = self
10276                .session_bindings
10277                .lock()
10278                .unwrap_or_else(|poisoned| poisoned.into_inner());
10279            if bindings
10280                .values()
10281                .any(|binding| &binding.workspace_id == workspace_id)
10282            {
10283                return Err(failure(
10284                    NodeFailureCode::WorkspaceBusy,
10285                    "worktree workspace retains a bound session; remove every session first",
10286                ));
10287            }
10288        }
10289        remove_git_worktree(&source_root, &target.path)
10290            .await
10291            .map_err(git_worktree_failure)?;
10292        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10293            self.unregister_workspace(workspace_id)
10294                .map_err(|error| failure(error.code, &error.message))?;
10295        }
10296        Ok(registered_workspace_id)
10297    }
10298
10299    async fn cleanup_managed_worktree(
10300        &self,
10301        lease_id: &ManagedWorktreeLeaseId,
10302        explicit: bool,
10303    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10304        let lease = self.managed_worktrees.lock()
10305            .unwrap_or_else(|poisoned| poisoned.into_inner())
10306            .get(lease_id)
10307            .cloned()
10308            .ok_or_else(|| failure(
10309                NodeFailureCode::UnknownManagedWorktreeLease,
10310                "managed worktree lease does not exist",
10311            ))?;
10312        if lease.state == ManagedWorktreeLeaseState::Removed {
10313            return Ok(lease.snapshot());
10314        }
10315        if lease.has_holders() {
10316            return Err(failure(
10317                NodeFailureCode::ManagedWorktreeBusy,
10318                "managed worktree lease still has a session or durable-record holder",
10319            ));
10320        }
10321        if self
10322            .materializations
10323            .lock()
10324            .unwrap_or_else(|poisoned| poisoned.into_inner())
10325            .values()
10326            .any(|record| record.managed_lease_id() == Some(lease_id))
10327        {
10328            return Err(failure(
10329                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10330                "managed worktree cleanup is blocked by session environment ownership",
10331            ));
10332        }
10333        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10334            let snapshot = {
10335                let mut registry = self.managed_worktrees.lock()
10336                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10337                let current = registry.get_mut(lease_id)
10338                    .expect("managed worktree lease remains present");
10339                current.state = ManagedWorktreeLeaseState::Retained;
10340                current.cleanup_failure = None;
10341                current.updated_at_unix_ms = unix_time_ms();
10342                current.snapshot()
10343            };
10344            if self.persist_state().is_err() {
10345                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10346            }
10347            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10348            return Ok(snapshot);
10349        }
10350        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10351        if profile.revision() != &lease.profile_revision {
10352            self.mark_managed_recovery_required(
10353                lease_id,
10354                ManagedWorktreeCleanupFailure::OwnershipConflict,
10355            );
10356            return Err(failure(
10357                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10358                "managed worktree profile revision changed",
10359            ));
10360        }
10361        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10362            self.mark_managed_recovery_required(
10363                lease_id,
10364                ManagedWorktreeCleanupFailure::OwnershipConflict,
10365            );
10366            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10367        })?;
10368        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10369        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10370            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10371            managed_git_worktree_failure(error)
10372        })?;
10373        let target = listed.iter()
10374            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10375        if let Some(target) = target {
10376            if target.is_main || target.is_bare || !exact_owned_worktree(
10377                &lease,
10378                &target.path,
10379                target.branch.as_deref(),
10380                &target.head,
10381            ) {
10382                self.mark_managed_recovery_required(
10383                    lease_id,
10384                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10385                );
10386                return Err(failure(
10387                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10388                    "Git worktree identity no longer matches the managed lease",
10389                ));
10390            }
10391            if target.prunable {
10392                self.mark_managed_cleanup_blocked(
10393                    lease_id,
10394                    ManagedWorktreeCleanupFailure::Prunable,
10395                );
10396                return Err(failure(
10397                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10398                    "managed worktree is prunable and requires recovery",
10399                ));
10400            }
10401            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10402                let failure_kind = match error.kind {
10403                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10404                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10405                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10406                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10407                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10408                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10409                };
10410                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10411                    self.mark_managed_recovery_required(lease_id, failure_kind);
10412                } else {
10413                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10414                }
10415                return Err(managed_git_worktree_failure(error));
10416            }
10417        } else {
10418            match std::fs::symlink_metadata(&lease.target_root) {
10419                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10420                Ok(_) => {
10421                    self.mark_managed_recovery_required(
10422                        lease_id,
10423                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10424                    );
10425                    return Err(failure(
10426                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10427                        "managed target exists but is not owned by Git",
10428                    ));
10429                }
10430                Err(_) => {
10431                    self.mark_managed_cleanup_blocked(
10432                        lease_id,
10433                        ManagedWorktreeCleanupFailure::Backend,
10434                    );
10435                    return Err(failure(
10436                        NodeFailureCode::BackendOperationFailed,
10437                        "managed target identity could not be inspected",
10438                    ));
10439                }
10440            }
10441        }
10442        let registered = self.workspaces.read()
10443            .unwrap_or_else(|poisoned| poisoned.into_inner())
10444            .contains_key(&lease.workspace_id);
10445        if registered {
10446            self.unregister_workspace(&lease.workspace_id)?;
10447        }
10448        let removed = {
10449            self.managed_worktrees.lock()
10450                .unwrap_or_else(|poisoned| poisoned.into_inner())
10451                .tombstone(lease_id, unix_time_ms())
10452                .expect("managed worktree lease remains present during cleanup")
10453        };
10454        self.persist_state().map_err(persistence_failure)?;
10455        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10456        Ok(removed.snapshot())
10457    }
10458
10459    fn mark_managed_cleanup_blocked(
10460        &self,
10461        lease_id: &ManagedWorktreeLeaseId,
10462        failure_kind: ManagedWorktreeCleanupFailure,
10463    ) {
10464        let snapshot = {
10465            let mut registry = self.managed_worktrees.lock()
10466                .unwrap_or_else(|poisoned| poisoned.into_inner());
10467            let Some(lease) = registry.get_mut(lease_id) else { return };
10468            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10469            lease.cleanup_failure = Some(failure_kind);
10470            lease.updated_at_unix_ms = unix_time_ms();
10471            lease.snapshot()
10472        };
10473        let _ = self.persist_state();
10474        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10475    }
10476
10477    fn require_worktree_service(
10478        &self,
10479        source_workspace_id: &WorkspaceId,
10480    ) -> Result<(), NodeFailure> {
10481        if self.managed_worktrees.lock()
10482            .unwrap_or_else(|poisoned| poisoned.into_inner())
10483            .lease_for_workspace(source_workspace_id)
10484            .is_some()
10485        {
10486            return Err(failure(
10487                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10488                "managed worktree targets are internal-only workspaces",
10489            ));
10490        }
10491        match self
10492            .worktree_service_modes
10493            .get(source_workspace_id)
10494            .copied()
10495            .unwrap_or(WorktreeServiceMode::Manual)
10496        {
10497            WorktreeServiceMode::Manual => Ok(()),
10498            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10499                NodeFailureCode::UnsupportedSpawnCapability,
10500                "Git worktree service mode is unavailable in this node build",
10501            )),
10502        }
10503    }
10504
10505    fn managed_reservation_conflict(
10506        &self,
10507        workspace_id: Option<&WorkspaceId>,
10508        root: Option<&str>,
10509    ) -> bool {
10510        self.managed_worktrees.lock()
10511            .unwrap_or_else(|poisoned| poisoned.into_inner())
10512            .active_records()
10513            .any(|lease| {
10514                workspace_id.is_some_and(|workspace_id| {
10515                    workspace_id == &lease.source_workspace_id
10516                        || workspace_id == &lease.workspace_id
10517                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10518            })
10519    }
10520
10521    fn reject_managed_reservation(
10522        &self,
10523        workspace_id: Option<&WorkspaceId>,
10524        root: Option<&str>,
10525    ) -> Result<(), NodeFailure> {
10526        if self.managed_reservation_conflict(workspace_id, root) {
10527            Err(failure(
10528                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10529                "managed worktree ownership may only be mutated through its lease cleanup",
10530            ))
10531        } else {
10532            Ok(())
10533        }
10534    }
10535
10536    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10537        self.validate_address(address)?;
10538        self.workspace_root(&address.workspace_id)
10539    }
10540
10541    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10542    /// `require_policy`'s own doc on those two requirements), so they keep
10543    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10544    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10545    /// `false` on the `require_policy` call below for them because a PTY
10546    /// resume never needs those facts, not because this method cannot look
10547    /// them up.
10548    ///
10549    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10550    /// the SAME `binding.runtime_policy` every other requirement reads here
10551    /// (one computed policy, read by every gate). That policy is no longer
10552    /// PTY-terminal-verification-shaped for an ACP session --
10553    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10554    /// `provider_runtime::policy_for_transport` -- so consulting it for
10555    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10556    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10557    /// even though a prompt is an ACP session's only interface). Pipe keeps
10558    /// reading the same PTY-probe-derived value it always has --
10559    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10560    /// its own doc for why).
10561    ///
10562    /// What this method still adds on top, for ACP/Pipe only, is a
10563    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10564    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10565    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10566    /// read off the same live `SessionSnapshot` state `require_acp_session`
10567    /// already reads for the ACP control verbs -- `TurnStarted` observed
10568    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10569    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10570    /// its fate in vendor-specific behaviour this node cannot observe (one
10571    /// agent may queue it, another drop it, another interleave it), so this
10572    /// refuses it outright rather than gambling on that -- a rule with no
10573    /// PTY analogue, which is why PTY does not take this extra precondition.
10574    fn require_session_runtime_policy(
10575        &self,
10576        address: &SessionAddress,
10577        requirement: ProviderRuntimeRequirement,
10578    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10579        let runtime_policy = {
10580            let bindings = self
10581                .session_bindings
10582                .lock()
10583                .unwrap_or_else(|poisoned| poisoned.into_inner());
10584            let binding = bindings
10585                .get(&address.session.instance_id)
10586                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10587            if binding.workspace_id != address.workspace_id {
10588                return Err(failure(
10589                    NodeFailureCode::SessionWorkspaceMismatch,
10590                    "session belongs to another workspace",
10591                ));
10592            }
10593            if binding.generation != address.session.generation {
10594                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10595            }
10596            binding.runtime_policy
10597        };
10598        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10599            let snapshot = self.internal_session_snapshot(address)?;
10600            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10601                && snapshot.provider.lead_activity != ProviderActivity::Idle
10602            {
10603                return Err(failure(
10604                    NodeFailureCode::TurnInFlight,
10605                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10606                ));
10607            }
10608        }
10609        require_policy(runtime_policy, false, false, requirement)
10610            .map(|()| runtime_policy)
10611            .map_err(|_| failure(
10612                NodeFailureCode::UnsupportedCapability,
10613                "session runtime policy does not admit this semantic operation",
10614            ))
10615    }
10616
10617    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10618        let mut bindings = self
10619            .session_bindings
10620            .lock()
10621            .unwrap_or_else(|poisoned| poisoned.into_inner());
10622        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10623            binding.workspace_id == address.workspace_id
10624                && binding.generation == address.session.generation
10625        }) {
10626            let removed = bindings.remove(&address.session.instance_id);
10627            self.clear_terminal_frame_watermark(address);
10628            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10629            return removed;
10630        }
10631        None
10632    }
10633
10634    fn cleanup_session_owned_materialization(
10635        &self,
10636        address: &SessionAddress,
10637        binding: &SessionBinding,
10638    ) -> Result<(), NodeFailure> {
10639        let Some(id) = binding.materialization_id.as_ref() else {
10640            return Ok(());
10641        };
10642        let owner = self
10643            .materializations
10644            .lock()
10645            .unwrap_or_else(|poisoned| poisoned.into_inner())
10646            .get(id)
10647            .map(|record| record.owner().clone());
10648        match owner {
10649            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10650            Some(MaterializationOwner::Session {
10651                incarnation_id,
10652                instance_id,
10653                generation,
10654            }) if incarnation_id == self.incarnation_id
10655                && instance_id == address.session.instance_id
10656                && generation == address.session.generation =>
10657            {
10658                self.cleanup_materialization(id)
10659            }
10660            Some(MaterializationOwner::Session { .. }) => Err(failure(
10661                NodeFailureCode::BackendOperationFailed,
10662                "session environment ownership requires recovery",
10663            )),
10664        }
10665    }
10666
10667    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10668        let mut watermarks = self
10669            .terminal_frame_watermarks
10670            .lock()
10671            .unwrap_or_else(|poisoned| poisoned.into_inner());
10672        if watermarks
10673            .get(&address.session.instance_id)
10674            .is_some_and(|(current, _)| current == address)
10675        {
10676            watermarks.remove(&address.session.instance_id);
10677        }
10678    }
10679
10680    #[cfg(test)]
10681    fn address_for(
10682        &self,
10683        instance_id: AgentInstanceId,
10684        generation: SessionGeneration,
10685    ) -> Option<SessionAddress> {
10686        let binding = self.session_bindings
10687            .lock()
10688            .unwrap_or_else(|poisoned| poisoned.into_inner())
10689            .get(&instance_id)
10690            .cloned()?;
10691        (binding.generation == generation).then_some(SessionAddress {
10692            workspace_id: binding.workspace_id,
10693            session: SessionKey { instance_id, generation },
10694        })
10695    }
10696
10697    fn arm_resume(
10698        &self,
10699        address: &SessionAddress,
10700        command_id: CommandId,
10701        runtime_policy: ProviderRuntimePolicy,
10702    ) -> Result<(), NodeFailure> {
10703        let mut bindings = self
10704            .session_bindings
10705            .lock()
10706            .unwrap_or_else(|poisoned| poisoned.into_inner());
10707        let binding = bindings
10708            .get_mut(&address.session.instance_id)
10709            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10710        if binding.workspace_id != address.workspace_id {
10711            return Err(failure(
10712                NodeFailureCode::SessionWorkspaceMismatch,
10713                "session belongs to another workspace",
10714            ));
10715        }
10716        if binding.generation != address.session.generation {
10717            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10718        }
10719        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10720        Ok(())
10721    }
10722
10723    fn clear_armed_resume(&self, address: &SessionAddress) {
10724        let mut bindings = self
10725            .session_bindings
10726            .lock()
10727            .unwrap_or_else(|poisoned| poisoned.into_inner());
10728        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10729            if binding.workspace_id == address.workspace_id
10730                && binding.generation == address.session.generation
10731            {
10732                binding.pending_resume = None;
10733            }
10734        }
10735    }
10736
10737    fn publish_control(&self, event: ControlEvent) {
10738        let revokes_harness_mcp = matches!(
10739            &event.event,
10740            ControlEventKind::Exited { .. }
10741                | ControlEventKind::Failed { .. }
10742                | ControlEventKind::Removed
10743        );
10744        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10745            let mut bindings = self
10746                .session_bindings
10747                .lock()
10748                .unwrap_or_else(|poisoned| poisoned.into_inner());
10749            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10750                return;
10751            };
10752            let previous_address = SessionAddress {
10753                workspace_id: binding.workspace_id.clone(),
10754                session: SessionKey {
10755                    instance_id: event.instance_id,
10756                    generation: binding.generation,
10757                },
10758            };
10759            let mut resume_command_rejected = false;
10760            if binding.generation == event.generation {
10761                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10762                    let rejected_resume = matches!(
10763                        &event.event,
10764                        ControlEventKind::CommandRejected { .. }
10765                    ) && event.command_id == Some(pending_command_id);
10766                    let resume_failed = matches!(
10767                        &event.event,
10768                        ControlEventKind::ResumeDenied { .. }
10769                            | ControlEventKind::ResumeFailed { .. }
10770                    );
10771                    if pending_generation == binding.generation
10772                        && (rejected_resume || resume_failed)
10773                    {
10774                        resume_command_rejected = rejected_resume;
10775                        binding.pending_resume = None;
10776                    }
10777                }
10778            } else {
10779                let expected = binding.generation.0.checked_add(1);
10780                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10781                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10782                    generation == binding.generation
10783                })
10784                    || expected != Some(event.generation.0)
10785                    || !authorized
10786                {
10787                    return;
10788                }
10789                let (_, _, runtime_policy) = binding
10790                    .pending_resume
10791                    .expect("authorized resume retains its pending runtime policy");
10792                binding.generation = event.generation;
10793                binding.runtime_policy = runtime_policy;
10794                binding.pending_resume = None;
10795            }
10796            (
10797                SessionAddress {
10798                    workspace_id: binding.workspace_id.clone(),
10799                    session: SessionKey {
10800                        instance_id: event.instance_id,
10801                        generation: event.generation,
10802                    },
10803                },
10804                (previous_address.session.generation != event.generation)
10805                    .then_some(previous_address),
10806                binding.record_id.clone(),
10807                binding.runtime_policy.provider_session_identity,
10808                resume_command_rejected,
10809            )
10810        };
10811        if let Some(previous_address) = previous_address.as_ref() {
10812            self.clear_terminal_frame_watermark(previous_address);
10813        }
10814        if let Some(record_id) = record_id {
10815            self.reconcile_managed_record(
10816                &record_id,
10817                &address,
10818                &event,
10819                identity_admitted,
10820                resume_command_rejected,
10821            );
10822        }
10823        // Every state change a session's process lifecycle can reach gets a
10824        // line here: this is the single chokepoint every `ControlEventKind`
10825        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10826        // came from a fresh spawn or a resume. Per-message provider chatter
10827        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10828        // matched here -- that is exactly the per-frame noise this crate
10829        // family's level discipline forbids at INFO.
10830        match &event.event {
10831            ControlEventKind::Running { process_id } => {
10832                if let Some(pid) = process_id {
10833                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10834                }
10835                #[cfg(unix)]
10836                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10837                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10838                }
10839                tracing::info!(
10840                    node_id = %self.node_id,
10841                    workspace_id = %address.workspace_id,
10842                    session = ?address.session,
10843                    pid = ?process_id,
10844                    "session process is running",
10845                );
10846            }
10847            ControlEventKind::Exited { exit_code, forced } => {
10848                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10849                #[cfg(unix)]
10850                if let Some(proxy) = self.signer_proxy.get().cloned() {
10851                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10852                    if proxy.has_session(&key) {
10853                        // Revoke at core off the event path (blocking uplink call).
10854                        std::thread::spawn(move || proxy.release(&key));
10855                    }
10856                }
10857                tracing::info!(
10858                    node_id = %self.node_id,
10859                    workspace_id = %address.workspace_id,
10860                    session = ?address.session,
10861                    exit_code = ?exit_code,
10862                    forced = forced,
10863                    "session process exited",
10864                );
10865            }
10866            ControlEventKind::Failed { message } => {
10867                tracing::warn!(
10868                    node_id = %self.node_id,
10869                    workspace_id = %address.workspace_id,
10870                    session = ?address.session,
10871                    cause = %message,
10872                    "session failed",
10873                );
10874            }
10875            ControlEventKind::CommandRejected { message } => {
10876                tracing::warn!(
10877                    node_id = %self.node_id,
10878                    workspace_id = %address.workspace_id,
10879                    session = ?address.session,
10880                    cause = %message,
10881                    "session command rejected",
10882                );
10883            }
10884            ControlEventKind::Removed => {
10885                tracing::info!(
10886                    node_id = %self.node_id,
10887                    workspace_id = %address.workspace_id,
10888                    session = ?address.session,
10889                    "session removed",
10890                );
10891            }
10892            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10893                tracing::info!(
10894                    node_id = %self.node_id,
10895                    workspace_id = %address.workspace_id,
10896                    session = ?address.session,
10897                    provider_session = ?provider_session,
10898                    "session resume authorized",
10899                );
10900            }
10901            ControlEventKind::Resumed { session: provider_session, process_id } => {
10902                tracing::info!(
10903                    node_id = %self.node_id,
10904                    workspace_id = %address.workspace_id,
10905                    session = ?address.session,
10906                    provider_session = ?provider_session,
10907                    pid = ?process_id,
10908                    "session resumed",
10909                );
10910            }
10911            ControlEventKind::ResumeDenied { reason } => {
10912                tracing::warn!(
10913                    node_id = %self.node_id,
10914                    workspace_id = %address.workspace_id,
10915                    session = ?address.session,
10916                    cause = %reason,
10917                    "session resume denied",
10918                );
10919            }
10920            ControlEventKind::ResumeFailed { message } => {
10921                tracing::warn!(
10922                    node_id = %self.node_id,
10923                    workspace_id = %address.workspace_id,
10924                    session = ?address.session,
10925                    cause = %message,
10926                    "session resume failed",
10927                );
10928            }
10929            _ => {}
10930        }
10931        self.apply_pending_acp_options(&address, &event);
10932        self.apply_acp_auto_permit(&address, &event);
10933        trace_acp_turn(&address, &event);
10934        self.observe_acp_provider_error(&address, &event);
10935        let stream_chunk = agent_stream_chunk(&event);
10936        self.publish(NodeEvent::Control {
10937            address: address.clone(),
10938            event,
10939        });
10940        if let Some(chunk) = stream_chunk {
10941            self.publish_agent_stream_chunk(address.clone(), chunk);
10942        }
10943        if revokes_harness_mcp {
10944            SessionLifecycleHook::on_session_ended(self, &address);
10945        }
10946    }
10947
10948    fn reconcile_managed_record(
10949        &self,
10950        record_id: &SessionRecordId,
10951        address: &SessionAddress,
10952        event: &ControlEvent,
10953        identity_admitted: bool,
10954        resume_command_rejected: bool,
10955    ) {
10956        let _transaction = self
10957            .state_transaction
10958            .lock()
10959            .unwrap_or_else(|poisoned| poisoned.into_inner());
10960        let observed_identity = match &event.event {
10961            ControlEventKind::ProviderEvent {
10962                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10963                ..
10964            } if identity_admitted => Some(identity.clone()),
10965            _ => None,
10966        };
10967        let identity_was_observed = observed_identity.is_some();
10968        let replacement_id = observed_identity
10969            .as_ref()
10970            .and_then(|_| self.allocate_record_id().ok());
10971        let mut upserts = Vec::new();
10972        let mut removals = Vec::new();
10973        let mut rebind = None;
10974        let original_records;
10975        let original_materializations = self
10976            .materializations
10977            .lock()
10978            .unwrap_or_else(|poisoned| poisoned.into_inner())
10979            .clone();
10980        {
10981            let mut records = self
10982                .session_records
10983                .lock()
10984                .unwrap_or_else(|poisoned| poisoned.into_inner());
10985            original_records = records.records.clone();
10986            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10987                return;
10988            };
10989            if let Some(identity) = observed_identity {
10990                let existing_id = records
10991                    .records
10992                    .values()
10993                    .find(|record| {
10994                        record.record_id != *record_id
10995                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10996                                session_registry::same_provider_session(
10997                                    &record.provider,
10998                                    record_identity,
10999                                    &current_snapshot.provider,
11000                                    &identity,
11001                                )
11002                            })
11003                    })
11004                    .map(|record| record.record_id.clone());
11005                if let Some(existing_id) = existing_id {
11006                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11007                        record.mode == current_snapshot.mode
11008                            && record.workspace_id == current_snapshot.workspace_id
11009                            && record.environment_profile == current_snapshot.environment_profile
11010                            && record.bundle == current_snapshot.bundle
11011                            && record.context_id == current_snapshot.context_id
11012                            && record.context == current_snapshot.context
11013                            && record
11014                                .canonical_root
11015                                .as_utf8()
11016                                .zip(current_snapshot.canonical_root.as_utf8())
11017                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11018                    });
11019                    if !scope_matches {
11020                        if let Some(current) = records.records.get_mut(record_id) {
11021                            current.state = ManagedSessionState::Unavailable;
11022                            current.last_error =
11023                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11024                            current.updated_at_unix_ms = unix_time_ms();
11025                            upserts.push(current.clone());
11026                        }
11027                    } else {
11028                        let existing_is_active = records
11029                            .records
11030                            .get(&existing_id)
11031                            .is_some_and(|record| record.active_session.is_some());
11032                        if existing_is_active {
11033                            if let Some(current) = records.records.get_mut(record_id) {
11034                                current.state = ManagedSessionState::Unavailable;
11035                                current.last_error =
11036                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11037                                current.updated_at_unix_ms = unix_time_ms();
11038                                upserts.push(current.clone());
11039                            }
11040                        } else {
11041                            if current_snapshot.provider_session.is_some() {
11042                                if let Some(current) = records.records.get_mut(record_id) {
11043                                    current.active_session = None;
11044                                    current.state = ManagedSessionState::Dormant;
11045                                    current.updated_at_unix_ms = unix_time_ms();
11046                                    upserts.push(current.clone());
11047                                }
11048                            } else {
11049                                records.records.remove(record_id);
11050                                removals.push(record_id.clone());
11051                            }
11052                            if let Some(existing) = records.records.get_mut(&existing_id) {
11053                                existing.provider_session = Some(identity.clone());
11054                                existing.active_session = Some(address.clone());
11055                                existing.state = ManagedSessionState::Live;
11056                                existing.updated_at_unix_ms = unix_time_ms();
11057                                existing.last_error = None;
11058                                upserts.push(existing.clone());
11059                            }
11060                            rebind = Some(existing_id);
11061                        }
11062                    }
11063                } else if current_snapshot
11064                    .provider_session
11065                    .as_ref()
11066                    .is_some_and(|current| {
11067                        !session_registry::same_provider_session(
11068                            &current_snapshot.provider,
11069                            current,
11070                            &current_snapshot.provider,
11071                            &identity,
11072                        )
11073                    })
11074                {
11075                    if let Some(current) = records.records.get_mut(record_id) {
11076                        current.active_session = None;
11077                        if current.bundle.is_some() || current.context.is_some() {
11078                            let had_bundle = current.bundle.is_some();
11079                            current.provider_session = None;
11080                            current.bundle = None;
11081                            current.context_id = None;
11082                            current.context = None;
11083                            current.state = ManagedSessionState::Unavailable;
11084                            current.last_error = Some(if had_bundle {
11085                                "bundle-unavailable".to_owned()
11086                            } else {
11087                                "context-unavailable".to_owned()
11088                            });
11089                        } else {
11090                            current.state = ManagedSessionState::Dormant;
11091                        }
11092                        current.updated_at_unix_ms = unix_time_ms();
11093                        upserts.push(current.clone());
11094                    }
11095                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11096                        if let Some(current) = records.records.get_mut(record_id) {
11097                            current.state = ManagedSessionState::Unavailable;
11098                            current.last_error =
11099                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11100                            current.updated_at_unix_ms = unix_time_ms();
11101                            upserts.push(current.clone());
11102                        }
11103                    } else if let Some(new_record_id) = replacement_id {
11104                        let now = unix_time_ms();
11105                        let replacement = ManagedSessionRecord {
11106                            record_id: new_record_id.clone(),
11107                            display_name: format!(
11108                                "{} #{}",
11109                                current_snapshot.provider.as_str(),
11110                                records
11111                                    .records
11112                                    .values()
11113                                    .filter(|record| {
11114                                        record.provider == current_snapshot.provider
11115                                    })
11116                                    .count()
11117                                    .saturating_add(1),
11118                            ),
11119                            provider: current_snapshot.provider.clone(),
11120                            mode: current_snapshot.mode,
11121                            state: ManagedSessionState::Live,
11122                            workspace_id: current_snapshot.workspace_id,
11123                            canonical_root: current_snapshot.canonical_root,
11124                            provider_session: Some(identity),
11125                            active_session: Some(address.clone()),
11126                            environment_profile: current_snapshot.environment_profile,
11127                            bundle: current_snapshot.bundle,
11128                            context_id: current_snapshot.context_id,
11129                            context: current_snapshot.context,
11130                            // This is a fresh record_id representing the session under its
11131                            // newly-observed identity; it has not yet produced any clean
11132                            // exit of its own, so it starts with no exported pack (the old
11133                            // record, still addressable under its own record_id, keeps
11134                            // whatever exported_context it already had).
11135                            exported_context: None,
11136                            task_binding: current_snapshot.task_binding,
11137                            created_at_unix_ms: now,
11138                            updated_at_unix_ms: now,
11139                            last_error: None,
11140                        };
11141                        records
11142                            .records
11143                            .insert(new_record_id.clone(), replacement.clone());
11144                        upserts.push(replacement);
11145                        rebind = Some(new_record_id);
11146                    } else if let Some(current) = records.records.get_mut(record_id) {
11147                        current.state = ManagedSessionState::Unavailable;
11148                        current.last_error =
11149                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11150                        upserts.push(current.clone());
11151                    }
11152                } else if let Some(current) = records.records.get_mut(record_id) {
11153                    current.provider_session = Some(identity);
11154                    current.active_session = Some(address.clone());
11155                    current.state = ManagedSessionState::Live;
11156                    current.updated_at_unix_ms = unix_time_ms();
11157                    current.last_error = None;
11158                    upserts.push(current.clone());
11159                }
11160            } else if let Some(current) = records.records.get_mut(record_id) {
11161                if resume_command_rejected {
11162                    current.active_session = None;
11163                    current.state = detached_record_state(current);
11164                    current.updated_at_unix_ms = unix_time_ms();
11165                    current.last_error =
11166                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11167                    upserts.push(current.clone());
11168                } else {
11169                    match &event.event {
11170                    ControlEventKind::Running { .. }
11171                    | ControlEventKind::ResumeAuthorized { .. }
11172                    | ControlEventKind::Resumed { .. } => {
11173                        current.active_session = Some(address.clone());
11174                        current.state = active_record_state(
11175                            identity_admitted,
11176                            current.provider_session.is_some(),
11177                        );
11178                        current.updated_at_unix_ms = unix_time_ms();
11179                        current.last_error = None;
11180                        upserts.push(current.clone());
11181                    }
11182                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11183                        current.active_session = None;
11184                        current.state = detached_record_state(current);
11185                        current.updated_at_unix_ms = unix_time_ms();
11186                        upserts.push(current.clone());
11187                    }
11188                    ControlEventKind::Failed { message }
11189                    | ControlEventKind::ResumeFailed { message } => {
11190                        current.active_session = None;
11191                        current.state = detached_record_state(current);
11192                        current.updated_at_unix_ms = unix_time_ms();
11193                        current.last_error = Some(
11194                            session_registry::sanitized_record_error_summary(message),
11195                        );
11196                        upserts.push(current.clone());
11197                    }
11198                    ControlEventKind::ResumeDenied { reason } => {
11199                        current.active_session = None;
11200                        current.state = detached_record_state(current);
11201                        current.updated_at_unix_ms = unix_time_ms();
11202                        current.last_error = Some(
11203                            session_registry::sanitized_record_error_summary(reason),
11204                        );
11205                        upserts.push(current.clone());
11206                    }
11207                        _ => {}
11208                    }
11209                }
11210            }
11211        }
11212        let materialization_owner_record = identity_was_observed.then(|| {
11213            rebind.clone().unwrap_or_else(|| record_id.clone())
11214        }).and_then(|candidate| {
11215            self.session_records
11216                .lock()
11217                .unwrap_or_else(|poisoned| poisoned.into_inner())
11218                .records
11219                .get(&candidate)
11220                .filter(|record| {
11221                    record.provider_session.is_some()
11222                        && record.active_session.as_ref() == Some(address)
11223                })
11224                .map(|_| candidate)
11225        });
11226        if let Some(owner_record_id) = materialization_owner_record {
11227            let materialization_id = self
11228                .session_bindings
11229                .lock()
11230                .unwrap_or_else(|poisoned| poisoned.into_inner())
11231                .get(&address.session.instance_id)
11232                .and_then(|binding| binding.materialization_id.clone());
11233            if let Some(materialization_id) = materialization_id {
11234                let mut transfer_failed = false;
11235                {
11236                    let mut materializations = self
11237                        .materializations
11238                        .lock()
11239                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11240                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11241                        match ownership.owner() {
11242                            MaterializationOwner::Session {
11243                                incarnation_id,
11244                                instance_id,
11245                                generation,
11246                            } if *incarnation_id == self.incarnation_id
11247                                && *instance_id == address.session.instance_id
11248                                && *generation == address.session.generation =>
11249                            {
11250                                transfer_failed = ownership
11251                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11252                                    .is_err();
11253                            }
11254                            MaterializationOwner::Record { record_id }
11255                                if record_id == &owner_record_id => {}
11256                            MaterializationOwner::Record { record_id } => {
11257                                let expected = record_id.clone();
11258                                transfer_failed = ownership
11259                                    .transfer_record_owner(
11260                                        &expected,
11261                                        owner_record_id.clone(),
11262                                        unix_time_ms(),
11263                                    )
11264                                    .is_err();
11265                            }
11266                            _ => transfer_failed = true,
11267                        }
11268                        if transfer_failed {
11269                            let _ = ownership.mark_recovery_required(unix_time_ms());
11270                        }
11271                    } else {
11272                        transfer_failed = true;
11273                    }
11274                }
11275                if transfer_failed {
11276                    if let Some(record) = self
11277                        .session_records
11278                        .lock()
11279                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11280                        .records
11281                        .get_mut(&owner_record_id)
11282                    {
11283                        record.state = ManagedSessionState::Unavailable;
11284                        record.last_error = Some("environment-profile-unavailable".to_owned());
11285                        record.updated_at_unix_ms = unix_time_ms();
11286                        upserts.push(record.clone());
11287                    }
11288                }
11289            }
11290        }
11291        if let Some(new_record_id) = rebind.clone() {
11292            if let Some(binding) = self
11293                .session_bindings
11294                .lock()
11295                .unwrap_or_else(|poisoned| poisoned.into_inner())
11296                .get_mut(&address.session.instance_id)
11297            {
11298                binding.record_id = Some(new_record_id);
11299            }
11300        }
11301        if upserts.is_empty() && removals.is_empty() {
11302            return;
11303        }
11304        if self.persist_state_locked().is_err() {
11305            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11306            let mut records = self
11307                .session_records
11308                .lock()
11309                .unwrap_or_else(|poisoned| poisoned.into_inner());
11310            records.records = original_records;
11311            *self
11312                .materializations
11313                .lock()
11314                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11315            if let Some(current) = records.records.get_mut(record_id) {
11316                current.state = ManagedSessionState::Unavailable;
11317                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11318                current.updated_at_unix_ms = unix_time_ms();
11319                upserts = vec![current.clone()];
11320            } else {
11321                upserts.clear();
11322            }
11323            removals.clear();
11324            drop(records);
11325            if let Some(binding) = self
11326                .session_bindings
11327                .lock()
11328                .unwrap_or_else(|poisoned| poisoned.into_inner())
11329                .get_mut(&address.session.instance_id)
11330            {
11331                binding.record_id = Some(record_id.clone());
11332            }
11333            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11334        }
11335        for record in upserts {
11336            self.publish_record(record);
11337        }
11338        for record_id in removals {
11339            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11340        }
11341    }
11342
11343    fn snapshot(&self) -> NodeSnapshot {
11344        let control = self.handle.snapshot();
11345        let bindings = self
11346            .session_bindings
11347            .lock()
11348            .unwrap_or_else(|poisoned| poisoned.into_inner())
11349            .clone();
11350        let workspace_roots = self
11351            .workspaces
11352            .read()
11353            .unwrap_or_else(|poisoned| poisoned.into_inner())
11354            .clone();
11355        let environment_profiles = self
11356            .environment_profiles
11357            .read()
11358            .unwrap_or_else(|poisoned| poisoned.into_inner());
11359        let mut workspaces = workspace_roots
11360            .iter()
11361            .map(|(workspace_id, canonical_root)| {
11362                (
11363                    workspace_id.clone(),
11364                    WorkspaceSnapshot {
11365                        workspace_id: workspace_id.clone(),
11366                        canonical_root: opaque_windows_path(canonical_root.clone()),
11367                        sessions: Vec::new(),
11368                        worktree_service_mode: Some(
11369                            self.worktree_service_modes
11370                                .get(workspace_id)
11371                                .copied()
11372                                .unwrap_or(WorktreeServiceMode::Manual),
11373                        ),
11374                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11375                            profiles: self
11376                                .managed_worktree_profiles
11377                                .get(workspace_id)
11378                                .into_iter()
11379                                .flat_map(BTreeMap::values)
11380                                .map(|profile| ManagedWorktreeProfileSummary {
11381                                    id: profile.profile_id().clone(),
11382                                    revision: profile.revision().clone(),
11383                                    retention: profile.retention(),
11384                                })
11385                                .collect(),
11386                        }),
11387                    },
11388                )
11389            })
11390            .collect::<BTreeMap<_, _>>();
11391        let mut agent_progress = Vec::new();
11392        for session in &control.sessions {
11393            let Some(binding) = bindings.get(&session.instance_id) else {
11394                continue;
11395            };
11396            if binding.generation != session.generation {
11397                continue;
11398            }
11399            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11400                let address = SessionAddress {
11401                    workspace_id: binding.workspace_id.clone(),
11402                    session: SessionKey {
11403                        instance_id: session.instance_id,
11404                        generation: session.generation,
11405                    },
11406                };
11407                if let Some(progress) =
11408                    agent_progress_from_provider_snapshot(address, &session.provider)
11409                {
11410                    agent_progress.push(progress);
11411                }
11412                workspace.sessions.push(session.clone());
11413            }
11414        }
11415        NodeSnapshot {
11416            node_id: self.node_id.clone(),
11417            enabled_providers: self.enabled_providers.clone(),
11418            provider_runtime_statuses: {
11419                let updates = self
11420                    .provider_runtime_status_updates
11421                    .read()
11422                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11423                ProviderRuntimeStatuses::new(
11424                    self.provider_runtime_statuses
11425                        .iter()
11426                        .filter(|status| !updates.contains_key(status.provider()))
11427                        .cloned()
11428                        .chain(updates.values().cloned()),
11429                )
11430                .expect("refreshed provider runtime inventory remains bounded and unique")
11431            },
11432            workspaces: workspaces.into_values().collect(),
11433            session_records: self
11434                .session_records
11435                .lock()
11436                .unwrap_or_else(|poisoned| poisoned.into_inner())
11437                .records
11438                .values()
11439                .cloned()
11440                .collect(),
11441            managed_worktrees: self.managed_worktrees
11442                .lock()
11443                .unwrap_or_else(|poisoned| poisoned.into_inner())
11444                .snapshots(),
11445            launch_inventory: Some(LaunchInventory {
11446                spawn_profiles: Some(
11447                    self.spawn_profiles
11448                        .iter()
11449                        .filter_map(|profile| {
11450                            resolved_spawn_profile_summary(profile, &environment_profiles)
11451                        })
11452                        .collect(),
11453                ),
11454                bundles: Some(
11455                    self.bundle_catalog
11456                        .read()
11457                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11458                        .iter()
11459                        .map(NodeBundle::receipt)
11460                        .collect(),
11461                ),
11462                // Opaque station allowlist ids from empty-default catalog /
11463                // optional startup loader. Never secrets. Dig2 probe stays stub.
11464                network_allowlists: {
11465                    let catalog = self
11466                        .network_allowlist_catalog
11467                        .read()
11468                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11469                    if catalog.is_empty() {
11470                        None
11471                    } else {
11472                        Some(catalog.ids().cloned().collect())
11473                    }
11474                },
11475            }),
11476            agent_progress,
11477        }
11478    }
11479
11480    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11481        assert!(
11482            !matches!(
11483                &event,
11484                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11485            ),
11486            "terminal frame and agent stream events must use their own dedicated live channel",
11487        );
11488        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11489        let sequence = history
11490            .last_sequence
11491            .checked_add(1)
11492            .expect("node event sequence exhausted");
11493        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11494            if let Some(evicted) = history.events.pop_front() {
11495                history.replay_floor_sequence = evicted
11496                    .sequence
11497                    .checked_add(1)
11498                    .expect("node replay floor sequence exhausted");
11499                history.removed_record_providers.remove(&evicted.sequence);
11500            }
11501        }
11502        match &event {
11503            NodeEvent::HarnessMcpReadCall { .. } => {
11504                panic!("transient harness MCP calls must not enter durable event history")
11505            }
11506            NodeEvent::SessionRecordUpserted { record } => {
11507                history
11508                    .record_providers
11509                    .insert(record.record_id.clone(), record.provider.clone());
11510            }
11511            NodeEvent::SessionRecordRemoved { record_id } => {
11512                if let Some(provider) = history.record_providers.remove(record_id) {
11513                    history.removed_record_providers.insert(sequence, provider);
11514                }
11515            }
11516            NodeEvent::ManagedWorktreeUpserted { .. }
11517            | NodeEvent::ManagedWorktreeRemoved { .. }
11518            | NodeEvent::Control { .. }
11519            | NodeEvent::ControllerChanged { .. }
11520            | NodeEvent::SessionRecordHistorySummarized { .. }
11521            | NodeEvent::WorkspaceAdded { .. }
11522            | NodeEvent::WorkspaceRemoved { .. }
11523            | NodeEvent::ResyncRequired { .. }
11524            | NodeEvent::AgentStream { .. }
11525            | NodeEvent::TerminalFrame { .. } => {}
11526        }
11527        let envelope = NodeEventEnvelope { sequence, event };
11528        history.events.push_back(envelope.clone());
11529        history.last_sequence = sequence;
11530        let _ = self.event_tx.send(envelope.clone());
11531        drop(history);
11532        envelope
11533    }
11534
11535    /// Publishes a transient (never durable) `NodeEvent` -- today only
11536    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11537    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11538    /// why this must never ride the shared `event_tx` bus). Returns whether
11539    /// the send reached at least one subscriber: `broadcast::Sender::send`
11540    /// fails in exactly one way, no active receiver at that instant, and
11541    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11542    /// return to fail the pending call immediately rather than leave it to
11543    /// expire silently at `LOCAL_READ_TIMEOUT`/
11544    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11545    fn publish_transient(&self, event: NodeEvent) -> bool {
11546        assert!(event.requires_harness_mcp_proxy_capability());
11547        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11548        let envelope = NodeEventEnvelope { sequence: 0, event };
11549        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11550            Ok(_) => true,
11551            Err(broadcast::error::SendError(undelivered)) => {
11552                if let Some(NodeEventEnvelope {
11553                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11554                    ..
11555                }) = undelivered.first() {
11556                    // `request.content_type` only, never a per-tool label --
11557                    // this crate does not decode `request.body` and does not
11558                    // hold `hatchery-harness-api` (Nested Control Plane
11559                    // doctrine, Law 3; see this crate's dependency on
11560                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11561                    tracing::warn!(
11562                        call_id = call_id.as_str(),
11563                        content_type = ?request.content_type,
11564                        cause = "no-subscribers",
11565                        "harness MCP proxy event could not be published",
11566                    );
11567                }
11568                false
11569            }
11570        }
11571    }
11572
11573    fn publish_terminal_frames(&self) {
11574        let snapshot = self.handle.snapshot();
11575        let bindings = self
11576            .session_bindings
11577            .lock()
11578            .unwrap_or_else(|poisoned| poisoned.into_inner());
11579        let candidates = snapshot
11580            .sessions
11581            .iter()
11582            .filter_map(|session| {
11583                let binding = bindings.get(&session.instance_id)?;
11584                if binding.generation != session.generation {
11585                    return None;
11586                }
11587                let frame = session.terminal_frame.clone()?;
11588                crate::session_signals::feed(
11589                    binding.workspace_id.as_str(),
11590                    session.instance_id.0,
11591                    session.agent_id.as_str(),
11592                    &frame.contents,
11593                );
11594                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11595                let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11596                if let Some(act) = act {
11597                    provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11598                }
11599                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11600                Some((
11601                    SessionAddress {
11602                        workspace_id: binding.workspace_id.clone(),
11603                        session: SessionKey {
11604                            instance_id: session.instance_id,
11605                            generation: session.generation,
11606                        },
11607                    },
11608                    frame,
11609                ))
11610            })
11611            .collect();
11612        drop(bindings);
11613        self.publish_terminal_frame_candidates(candidates);
11614    }
11615
11616    fn publish_terminal_frame_candidates(
11617        &self,
11618        candidates: Vec<(SessionAddress, TerminalFrame)>,
11619    ) {
11620        let mut watermarks = self
11621            .terminal_frame_watermarks
11622            .lock()
11623            .unwrap_or_else(|poisoned| poisoned.into_inner());
11624        let mut events = Vec::with_capacity(candidates.len());
11625        for (address, frame) in candidates {
11626            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11627            let advanced = watermarks
11628                .get(&address.session.instance_id)
11629                .map_or(true, |(current_address, sequence)| {
11630                    current_address != &address || frame.sequence > *sequence
11631                });
11632            if !advanced {
11633                continue;
11634            }
11635            if is_first_frame {
11636                // Do NOT sessionize on first PTY output: a session is a
11637                // provider conversation id, not bytes on the wire. Drafts
11638                // leave the provisional map only when HQ attaches
11639                // (Resize / TerminalBytes). The attach-never-arrived timer
11640                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11641                // disconnect grace starts only on a real client WS close.
11642                // This is the first PTY output this node has observed for
11643                // this session -- a one-time event per spawn, not per-frame
11644                // chatter (the watermark makes sure of that). A session that
11645                // reaches this line with no controller attached anywhere on
11646                // the node is running unobserved: exactly how processes from
11647                // repeated spawn attempts went orphaned for hours without a
11648                // single line about it.
11649                if self.controller_state().is_none() {
11650                    tracing::warn!(
11651                        node_id = %self.node_id,
11652                        workspace_id = %address.workspace_id,
11653                        session = ?address.session,
11654                        terminal_size = ?frame.size,
11655                        "session producing output with no controller attached",
11656                    );
11657                } else {
11658                    tracing::info!(
11659                        node_id = %self.node_id,
11660                        workspace_id = %address.workspace_id,
11661                        session = ?address.session,
11662                        terminal_size = ?frame.size,
11663                        "session produced its first output",
11664                    );
11665                }
11666            }
11667            watermarks.insert(
11668                address.session.instance_id,
11669                (address.clone(), frame.sequence),
11670            );
11671            if let Some((at, reported)) = self
11672                .pty_input_marks
11673                .lock()
11674                .unwrap_or_else(|poisoned| poisoned.into_inner())
11675                .get_mut(&address.session.instance_id)
11676            {
11677                // First frame after an input: PTY write -> output published.
11678                if !*reported && at.elapsed() < Duration::from_secs(5) {
11679                    *reported = true;
11680                    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");
11681                }
11682            }
11683            events.push(NodeEvent::TerminalFrame { address, frame });
11684        }
11685        drop(watermarks);
11686        if events.is_empty() {
11687            return;
11688        }
11689        let mut history = self
11690            .history
11691            .lock()
11692            .unwrap_or_else(|poisoned| poisoned.into_inner());
11693        let mut envelopes = Vec::with_capacity(events.len());
11694        for event in events {
11695            let sequence = history
11696                .last_sequence
11697                .checked_add(1)
11698                .expect("node event sequence exhausted");
11699            history.last_sequence = sequence;
11700            envelopes.push(NodeEventEnvelope { sequence, event });
11701        }
11702        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11703        drop(history);
11704    }
11705
11706    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11707    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11708    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11709    /// counter every other event kind shares, never a counter of its own --
11710    /// `queue_connection_event`/`queue_connection_event_batch` compare
11711    /// `envelope.sequence` against one `discard_events_through` watermark
11712    /// regardless of which broadcast channel it arrived on, so a
11713    /// per-channel counter would desynchronize that comparison.
11714    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11715        let mut history = self
11716            .history
11717            .lock()
11718            .unwrap_or_else(|poisoned| poisoned.into_inner());
11719        let sequence = history
11720            .last_sequence
11721            .checked_add(1)
11722            .expect("node event sequence exhausted");
11723        history.last_sequence = sequence;
11724        drop(history);
11725        // Debug, not info/warn -- this fires on every single chunk, but it
11726        // is exactly the counter the next drop-measurement pass needs: what
11727        // the node actually emitted (`source_sequence`, `kind`) versus what
11728        // a harness subscriber's own forwarder later logs having received
11729        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11730        // event"), so a gap between the two logs names itself instead of
11731        // needing a new counter added ad hoc.
11732        tracing::debug!(
11733            source_sequence = chunk.source_sequence,
11734            kind = agent_stream_chunk_kind_label(&chunk.kind),
11735            envelope_sequence = sequence,
11736            "publishing agent stream chunk",
11737        );
11738        let envelope = NodeEventEnvelope {
11739            sequence,
11740            event: NodeEvent::AgentStream { address, chunk },
11741        };
11742        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11743    }
11744
11745    fn current_sequence(&self) -> u64 {
11746        self.history
11747            .lock()
11748            .unwrap_or_else(|poisoned| poisoned.into_inner())
11749            .last_sequence
11750    }
11751
11752    fn controller_state(&self) -> Option<ControllerState> {
11753        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11754        let lease = controller.as_ref().copied()?;
11755        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11756        if remaining.is_zero() {
11757            *controller = None;
11758            return None;
11759        }
11760        Some(ControllerState {
11761            connection_id: lease.connection_id,
11762            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11763        })
11764    }
11765
11766    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11767        if role != ClientRole::Operator {
11768            tracing::warn!(
11769                node_id = %self.node_id,
11770                connection_id,
11771                "controller lease rejected: observer connections cannot acquire control",
11772            );
11773            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11774        }
11775        let lease_ms = if lease_ms == 0 {
11776            DEFAULT_CONTROLLER_LEASE_MS
11777        } else {
11778            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11779        };
11780        let now = Instant::now();
11781        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11782        if let Some(active) = controller.as_ref() {
11783            if active.connection_id != connection_id && active.expires_at > now {
11784                tracing::warn!(
11785                    node_id = %self.node_id,
11786                    connection_id,
11787                    holder_connection_id = active.connection_id,
11788                    "controller lease rejected: another operator holds it",
11789                );
11790                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11791            }
11792        }
11793        // A reconnect (this connection already holds the lease, renewing
11794        // before expiry) happens on a periodic cadence; logging it at INFO
11795        // every renewal would be exactly the heartbeat chatter this crate
11796        // family's level discipline forbids. A genuine attach -- the
11797        // controller identity actually changing -- is a state change and
11798        // gets INFO; a same-connection renewal gets DEBUG.
11799        let is_new_attach = match controller.as_ref() {
11800            Some(active) => active.connection_id != connection_id,
11801            None => true,
11802        };
11803        *controller = Some(ControllerLease {
11804            connection_id,
11805            expires_at: now + Duration::from_millis(lease_ms),
11806        });
11807        drop(controller);
11808        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11809        if is_new_attach {
11810            tracing::info!(
11811                node_id = %self.node_id,
11812                connection_id,
11813                lease_ms,
11814                "controller lease acquired",
11815            );
11816        } else {
11817            tracing::debug!(
11818                node_id = %self.node_id,
11819                connection_id,
11820                lease_ms,
11821                "controller lease renewed",
11822            );
11823        }
11824        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11825        Ok(state)
11826    }
11827
11828    fn release_controller(&self, connection_id: u64) -> bool {
11829        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11830        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11831        if released {
11832            *controller = None;
11833        }
11834        drop(controller);
11835        if released {
11836            tracing::info!(
11837                node_id = %self.node_id,
11838                connection_id,
11839                "controller lease released",
11840            );
11841            self.publish(NodeEvent::ControllerChanged { controller: None });
11842        }
11843        released
11844    }
11845
11846    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11847    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11848    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11849    /// provider session; it only cancels the orphan timer. Disconnect/reload
11850    /// draft kills are owned by HQ and arm only on a real client WS close.
11851    /// ACP path of launch options: once the agent advertises its config options, write the
11852    /// pending model / effort through session/set_config_option (session/set_model when the
11853    /// agent has no model option). Gaps are logged, never forced.
11854    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11855        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11856            return;
11857        };
11858        let id = address.session.instance_id;
11859        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11860            return;
11861        };
11862        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11863            .iter()
11864            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11865            .collect();
11866        let (writes, set_model, effort_gap) =
11867            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11868        if set_model {
11869            if let Some(model_id) = model.clone() {
11870                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11871                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11872                }
11873            }
11874        }
11875        for (option_id, value_json) in writes {
11876            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11877                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11878            }
11879        }
11880        if effort_gap {
11881            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11882        }
11883        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11884    }
11885
11886    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11887    /// answer every session/request_permission with approve.
11888    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11889        let id = address.session.instance_id;
11890        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11891        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11892            crate::auto_permit::forget_acp(id.0);
11893            return;
11894        }
11895        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11896        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11897            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11898        }
11899        match provider_event {
11900            ProviderEvent::ModeChanged { mode_id, available } => {
11901                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11902                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11903                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11904                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11905                    }
11906                }
11907            }
11908            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11909                match self.dispatch(ControlCommand::ResolveInteraction {
11910                    instance_id: id,
11911                    generation: address.session.generation,
11912                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11913                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11914                }) {
11915                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11916                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11917                }
11918            }
11919            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11920            // must not drop the auto-permit registration; that happens on process exit below.
11921            _ => {}
11922        }
11923    }
11924
11925    /// ACP side of the provider error classifier: remember the turn's prompt, reset on a clean
11926    /// end, classify a turn that ended with a provider error (or a provider `Error` event).
11927    fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
11928        let id = address.session.instance_id.0;
11929        let found = match &event.event {
11930            ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11931                self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
11932                return;
11933            }
11934            ControlEventKind::ProviderEvent { event: e, .. } => match e {
11935                ProviderEvent::TurnStarted { prompt } => {
11936                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
11937                    return;
11938                }
11939                ProviderEvent::SessionEnded { is_error: false, .. } => {
11940                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ok(id);
11941                    return;
11942                }
11943                ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => crate::provider_error::classify_message(message, vendor_code.as_deref()),
11944                ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11945                _ => return,
11946            },
11947            _ => return,
11948        };
11949        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11950        let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11951        if let Some(act) = act {
11952            provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11953        }
11954    }
11955
11956    /// Run provider-error retries whose backoff is over (called from the drive loop).
11957    fn run_due_provider_retries(&self) {
11958        let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11959        for (id, how, attempt) in due {
11960            let instance_id = AgentInstanceId(id);
11961            let r = match how {
11962                crate::provider_error::Retry::Pty => self
11963                    .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11964                    .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11965                crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11966                    instance_id,
11967                    action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11968                }),
11969            };
11970            match r {
11971                Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11972                Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11973            }
11974        }
11975    }
11976
11977    fn mark_spawn_provisional(&self, address: SessionAddress) {
11978        let id = address.session.instance_id;
11979        self.provisional_spawns
11980            .lock()
11981            .unwrap_or_else(|poisoned| poisoned.into_inner())
11982            .insert(id, (address, Instant::now()));
11983        tracing::info!(
11984            node_id = %self.node_id,
11985            instance_id = id.0,
11986            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11987            "draft spawn; waiting for HQ view",
11988        );
11989    }
11990
11991    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11992        let removed = self
11993            .provisional_spawns
11994            .lock()
11995            .unwrap_or_else(|poisoned| poisoned.into_inner())
11996            .remove(&instance_id);
11997        if removed.is_some() {
11998            tracing::info!(
11999                node_id = %self.node_id,
12000                instance_id = instance_id.0,
12001                "draft spawn claimed by HQ view",
12002            );
12003        }
12004    }
12005
12006    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12007        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12008        let mut map = self
12009            .provisional_spawns
12010            .lock()
12011            .unwrap_or_else(|poisoned| poisoned.into_inner());
12012        let due: Vec<AgentInstanceId> = map
12013            .iter()
12014            .filter(|(_, (_, at))| at.elapsed() >= grace)
12015            .map(|(id, _)| *id)
12016            .collect();
12017        due.into_iter()
12018            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12019            .collect()
12020    }
12021
12022    async fn reap_provisional_spawn(&self, address: SessionAddress) {
12023        let instance_id = address.session.instance_id;
12024        tracing::warn!(
12025            node_id = %self.node_id,
12026            instance_id = instance_id.0,
12027            workspace = %address.workspace_id,
12028            reason = "attach_never_arrived",
12029            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12030            "draft spawn never viewed; stopping PTY",
12031        );
12032        let _ = self.dispatch(ControlCommand::Stop {
12033            instance_id,
12034            force: true,
12035        });
12036        let _ = self.remove_session(&address).await;
12037    }
12038
12039    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12040        if role != ClientRole::Operator {
12041            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12042        }
12043        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12044        let now = Instant::now();
12045        match controller.as_ref().copied() {
12046            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12047            Some(active) if active.expires_at <= now => {
12048                *controller = None;
12049                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12050            }
12051            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12052        }
12053    }
12054
12055    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12056        if !self
12057            .workspaces
12058            .read()
12059            .unwrap_or_else(|poisoned| poisoned.into_inner())
12060            .contains_key(&address.workspace_id)
12061        {
12062            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12063        }
12064        let binding = self
12065            .session_bindings
12066            .lock()
12067            .unwrap_or_else(|poisoned| poisoned.into_inner())
12068            .get(&address.session.instance_id)
12069            .cloned()
12070            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12071        if binding.workspace_id != address.workspace_id {
12072            return Err(failure(
12073                NodeFailureCode::SessionWorkspaceMismatch,
12074                "session belongs to another workspace",
12075            ));
12076        }
12077        if binding.generation != address.session.generation {
12078            return Err(failure(
12079                NodeFailureCode::StaleGeneration,
12080                &format!(
12081                    "stale session generation: expected {}, received {}",
12082                    binding.generation.0,
12083                    address.session.generation.0,
12084                ),
12085            ));
12086        }
12087        let snapshot = self.handle.snapshot();
12088        let Some(current) = snapshot
12089            .sessions
12090            .iter()
12091            .find(|item| item.instance_id == address.session.instance_id)
12092        else {
12093            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12094        };
12095        if current.generation != address.session.generation {
12096            return Err(failure(
12097                NodeFailureCode::StaleGeneration,
12098                &format!(
12099                    "stale session generation: expected {}, received {}",
12100                    current.generation.0,
12101                    address.session.generation.0,
12102                ),
12103            ));
12104        }
12105        Ok(current.agent_id.clone())
12106    }
12107
12108    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12109        self.dispatch_envelope(self.prepare_command(command))
12110    }
12111
12112    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12113        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12114        CommandEnvelope {
12115            id: CommandId(id),
12116            command,
12117        }
12118    }
12119
12120    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12121        let command_id = envelope.id;
12122        self.handle
12123            .dispatch(envelope)
12124            .map_err(|error| match error {
12125                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12126                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12127            })?;
12128        Ok(command_id)
12129    }
12130
12131    /// Records the kernel's own reason for asynchronously refusing
12132    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12133    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12134    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12135    /// first, and if the map is still at capacity after that, the single
12136    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12137    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12138    /// is always the oldest.
12139    fn record_rejected_command(
12140        &self,
12141        command_id: CommandId,
12142        message: String,
12143        unsupported_transport: bool,
12144    ) {
12145        let now = Instant::now();
12146        let mut reasons = self.rejected_command_reasons.lock()
12147            .unwrap_or_else(|poisoned| poisoned.into_inner());
12148        reasons.retain(|_, reason| reason.expires_at > now);
12149        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12150            if let Some(&oldest) = reasons.keys().next() {
12151                reasons.remove(&oldest);
12152            }
12153        }
12154        reasons.insert(
12155            command_id,
12156            RejectedCommandReason {
12157                message,
12158                unsupported_transport,
12159                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12160            },
12161        );
12162    }
12163
12164    /// Removes and returns the kernel's reason for refusing `command_id`,
12165    /// if the drive loop has recorded one and it has not expired -- see
12166    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12167    /// wait loop calls this instead of blindly polling out its whole
12168    /// deadline when the command it is waiting on turns out to have been
12169    /// refused rather than merely slow.
12170    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12171        let now = Instant::now();
12172        let mut reasons = self.rejected_command_reasons.lock()
12173            .unwrap_or_else(|poisoned| poisoned.into_inner());
12174        match reasons.remove(&command_id) {
12175            Some(reason) if reason.expires_at > now => Some(reason),
12176            _ => None,
12177        }
12178    }
12179
12180    async fn dispatch_bounded(
12181        &self,
12182        command: ControlCommand,
12183        timeout_duration: Duration,
12184    ) -> Result<CommandId, NodeFailure> {
12185        let deadline = Instant::now() + timeout_duration;
12186        loop {
12187            match self.dispatch(command.clone()) {
12188                Ok(command_id) => return Ok(command_id),
12189                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12190                    sleep(Duration::from_millis(2)).await;
12191                }
12192                Err(error) => return Err(error),
12193            }
12194        }
12195    }
12196
12197    fn internal_session_snapshot(
12198        &self,
12199        address: &SessionAddress,
12200    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12201        self.validate_address(address)?;
12202        self.handle
12203            .snapshot()
12204            .sessions
12205            .clone()
12206            .into_iter()
12207            .find(|session| {
12208                session.instance_id == address.session.instance_id
12209                    && session.generation == address.session.generation
12210            })
12211            .ok_or_else(|| {
12212                failure(
12213                    NodeFailureCode::UnknownSession,
12214                    "session snapshot is unavailable",
12215                )
12216            })
12217    }
12218
12219    /// Confirms `address` names a live session spawned over the ACP
12220    /// transport and returns its current snapshot -- the gate every
12221    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12222    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12223    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12224    /// the session's own recorded `TransportKind` does, and that lives on
12225    /// the live snapshot, not the binding.
12226    fn require_acp_session(
12227        &self,
12228        address: &SessionAddress,
12229    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12230        let snapshot = self.internal_session_snapshot(address)?;
12231        if snapshot.transport != TransportKind::Acp {
12232            return Err(failure(
12233                NodeFailureCode::UnsupportedTransport,
12234                "this verb requires a session spawned over the ACP transport",
12235            ));
12236        }
12237        Ok(snapshot)
12238    }
12239
12240    async fn dispatch_history_bounded(
12241        &self,
12242        address: &SessionAddress,
12243        command: ControlCommand,
12244        operation: HistoryOperation,
12245    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12246        self.validate_address(address)?;
12247        let started_after = self.current_sequence();
12248        let command_id = self
12249            .dispatch_bounded(
12250                command,
12251                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12252            )
12253            .await?;
12254        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12255        let mut scan_after = started_after;
12256        let mut accepted_after = None;
12257        loop {
12258            let events = self
12259                .history
12260                .lock()
12261                .unwrap_or_else(|poisoned| poisoned.into_inner())
12262                .events
12263                .iter()
12264                .filter(|envelope| envelope.sequence > scan_after)
12265                .cloned()
12266                .collect::<Vec<_>>();
12267            for envelope in events {
12268                scan_after = scan_after.max(envelope.sequence);
12269                let NodeEvent::Control {
12270                    address: event_address,
12271                    event,
12272                } = envelope.event
12273                else {
12274                    continue;
12275                };
12276                if event_address != *address {
12277                    continue;
12278                }
12279                if event.command_id == Some(command_id) {
12280                    match &event.event {
12281                        ControlEventKind::CommandRejected { .. } => {
12282                            return Err(failure(
12283                                NodeFailureCode::InvalidRequest,
12284                                "history request was rejected",
12285                            ));
12286                        }
12287                        ControlEventKind::HistoryRequested {
12288                            operation: requested,
12289                            ..
12290                        } if requested == &operation => {
12291                            accepted_after = Some(envelope.sequence);
12292                            continue;
12293                        }
12294                        _ => {}
12295                    }
12296                }
12297                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12298                    match (&operation, &event.event) {
12299                        (
12300                            HistoryOperation::Discover { .. },
12301                            ControlEventKind::HistoryDiscovered { .. },
12302                        )
12303                        | (
12304                            HistoryOperation::Load { .. },
12305                            ControlEventKind::HistoryLoaded { .. },
12306                        ) => return self.internal_session_snapshot(address),
12307                        (_, ControlEventKind::HistoryFailed { .. }) => {
12308                            return Err(failure(
12309                                NodeFailureCode::BackendOperationFailed,
12310                                "history operation failed",
12311                            ));
12312                        }
12313                        _ => {}
12314                    }
12315                }
12316            }
12317            if Instant::now() >= deadline {
12318                return Err(failure(
12319                    NodeFailureCode::BackendBusy,
12320                    if accepted_after.is_some() {
12321                        "history operation did not settle before the bounded deadline"
12322                    } else {
12323                        "history operation was not accepted before the bounded deadline"
12324                    },
12325                ));
12326            }
12327            sleep(Duration::from_millis(2)).await;
12328        }
12329    }
12330
12331    async fn discover_history(
12332        &self,
12333        address: &SessionAddress,
12334        limit: u16,
12335    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12336        let query = HistoryQuery {
12337            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12338            limit,
12339        };
12340        query
12341            .validate()
12342            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12343        let snapshot = self
12344            .dispatch_history_bounded(
12345                address,
12346                ControlCommand::DiscoverHistory {
12347                    instance_id: address.session.instance_id,
12348                    query: query.clone(),
12349                },
12350                HistoryOperation::Discover { query },
12351            )
12352            .await?;
12353        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12354            return Err(failure(
12355                NodeFailureCode::BackendOperationFailed,
12356                "history discovery settled into an invalid state",
12357            ));
12358        }
12359        Ok(snapshot.history.candidates)
12360    }
12361
12362    async fn load_history(
12363        &self,
12364        address: &SessionAddress,
12365        candidate_id: String,
12366    ) -> Result<HistorySessionRecord, NodeFailure> {
12367        validate_candidate_id(&candidate_id)
12368            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12369        let snapshot = self
12370            .dispatch_history_bounded(
12371                address,
12372                ControlCommand::LoadHistory {
12373                    instance_id: address.session.instance_id,
12374                    candidate_id: candidate_id.clone(),
12375                },
12376                HistoryOperation::Load {
12377                    candidate_id: candidate_id.clone(),
12378                },
12379            )
12380            .await?;
12381        if snapshot.history.pending.is_some()
12382            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12383        {
12384            return Err(failure(
12385                NodeFailureCode::BackendOperationFailed,
12386                "history load settled into an invalid state",
12387            ));
12388        }
12389        snapshot.history.loaded.ok_or_else(|| {
12390            failure(
12391                NodeFailureCode::BackendOperationFailed,
12392                "history load produced no bounded session",
12393            )
12394        })
12395    }
12396
12397    /// `allow_clean_detachment`: `false` for the on-demand
12398    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12399    /// managed record must stay untouched, still live, for the whole call);
12400    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12401    /// reacts to the very clean-exit event that detaches the record out from
12402    /// under this same call — see `session_record_export_target_matches`.
12403    async fn export_context_pack_for_session_record(
12404        &self,
12405        record_id: &SessionRecordId,
12406        session: &SessionAddress,
12407        allow_clean_detachment: bool,
12408    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12409        // C2 replaces a relayed failure's prose with a canned per-code
12410        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12411        // distinct refusals are indistinguishable to anyone downstream --
12412        // and a refusal here expires a continuation, which keeps no field
12413        // for why. The prose stays on the node, where it was written.
12414        let outcome = self
12415            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12416            .await;
12417        if let Err(failure) = &outcome {
12418            tracing::warn!(
12419                record = %record_id,
12420                code = ?failure.code,
12421                reason = %failure.message,
12422                "session-record context-pack export refused",
12423            );
12424        }
12425        outcome
12426    }
12427
12428    async fn export_context_pack_for_session_record_inner(
12429        &self,
12430        record_id: &SessionRecordId,
12431        session: &SessionAddress,
12432        allow_clean_detachment: bool,
12433    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12434        // Several steps here refuse with the same BackendOperationFailed
12435        // code and no prose of their own, so which step failed is
12436        // otherwise unrecoverable -- and a refusal here expires a
12437        // continuation.
12438        let step = |name: &'static str| move |failure: NodeFailure| {
12439            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12440            failure
12441        };
12442        let record = self.record(record_id).map_err(step("record-lookup"))?;
12443        // `provider_session` is `Some` only for a provider whose resolved
12444        // runtime policy admits live identity verification (raw PTY plus a
12445        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12446        // The PTY-sidecar-observation shape (no fleet provider today, but
12447        // capability-keyed rather than vendor-locked) hardcodes
12448        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12449        // so its records go straight to `Live` with `provider_session: None`
12450        // and stay that way for the record's entire life -- that is this
12451        // shape's permanent resting state, not a race with observation, and
12452        // `discover_history`/`load_history` below do not need an identity to
12453        // do their job for it. `IdentityPending` is the actual race (an
12454        // identity-admitted provider whose observation has not landed yet):
12455        // it keeps refusing here rather than falling back, because that
12456        // provider's own history the fallback would otherwise trust is not
12457        // guaranteed to be exact without the identity it is still waiting on.
12458        let identity = match record.provider_session.clone() {
12459            Some(identity) => {
12460                if identity.key != ProviderSessionKey::SessionId {
12461                    return Err(step("provider-identity-key")(failure(
12462                        NodeFailureCode::BackendOperationFailed,
12463                        "managed session provider identity cannot bind history exactly",
12464                    )));
12465                }
12466                Some(identity)
12467            }
12468            None if record.state == ManagedSessionState::IdentityPending => {
12469                return Err(step("provider-session-identity")(failure(
12470                    NodeFailureCode::BackendOperationFailed,
12471                    "managed session has not yet observed its provider session identity",
12472                )));
12473            }
12474            None => None,
12475        };
12476        self.revalidate_session_record_context_export(
12477            &record,
12478            identity.as_ref(),
12479            session,
12480            allow_clean_detachment,
12481        ).map_err(step("revalidate-before-discovery"))?;
12482        let candidates = self
12483            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12484            .await
12485            .map_err(step("discover-history"))?;
12486        // With no verified identity to filter by, the discovered set must
12487        // still resolve to exactly one candidate: the same ambiguity refusal
12488        // an identity filter would otherwise produce is the only safety
12489        // property left available for this shape.
12490        let candidate_id = select_history_candidate(
12491            &candidates,
12492            identity.as_ref().map(|identity| identity.id.as_str()),
12493        ).map_err(step("unique-candidate"))?;
12494        let loaded = self.load_history(session, candidate_id.clone()).await
12495            .map_err(step("load-history"))?;
12496        if let Some(identity) = identity.as_ref() {
12497            if loaded.session_id != identity.id {
12498                return Err(failure(
12499                    NodeFailureCode::SessionRecordConflict,
12500                    "loaded history identity does not match the managed session record",
12501                ));
12502            }
12503        }
12504        self.revalidate_session_record_context_export(
12505            &record,
12506            identity.as_ref(),
12507            session,
12508            allow_clean_detachment,
12509        )?;
12510        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12511            .map_err(step("revalidate-loaded-history"))?;
12512        let pack = self.materialize_context_pack(session).await
12513            .map_err(step("materialize-context-pack"))?;
12514        self.commit_context_pack_for_session_record(
12515            &record,
12516            identity.as_ref(),
12517            session,
12518            &candidate_id,
12519            &loaded,
12520            pack,
12521            allow_clean_detachment,
12522        )
12523    }
12524
12525    async fn export_context_pack(
12526        &self,
12527        address: &SessionAddress,
12528    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12529        let pack = self.materialize_context_pack(address).await?;
12530        self.commit_context_pack(pack)
12531    }
12532
12533    async fn materialize_context_pack(
12534        &self,
12535        address: &SessionAddress,
12536    ) -> Result<NodeContextPack, NodeFailure> {
12537        let snapshot = self.internal_session_snapshot(address)?;
12538        if !context_pack_source_status_is_usable(&snapshot.status) {
12539            return Err(failure(
12540                NodeFailureCode::InvalidRequest,
12541                "context export requires a running or successfully exited source session",
12542            ));
12543        }
12544        if snapshot.history.pending.is_some()
12545            || snapshot.history.loaded_candidate_id.is_none()
12546        {
12547            return Err(failure(
12548                NodeFailureCode::InvalidRequest,
12549                "context export requires settled loaded history",
12550            ));
12551        }
12552        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12553            failure(
12554                NodeFailureCode::InvalidRequest,
12555                "context export requires settled loaded history",
12556            )
12557        })?;
12558        let source_provider = snapshot.agent_id.clone();
12559        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12560        let repository = self
12561            .context_pack_repository(&address.workspace_id)
12562            .await?;
12563        let current = self.internal_session_snapshot(address)?;
12564        if !context_pack_source_status_is_usable(&current.status)
12565            || current.agent_id != source_provider
12566            || current.history.pending.is_some()
12567            || current.history.loaded_candidate_id != loaded_candidate_id
12568            || current.history.loaded.as_ref() != Some(&loaded)
12569        {
12570            return Err(failure(
12571                NodeFailureCode::ContextPackMaterializationFailed,
12572                "context source changed during bounded repository capture",
12573            ));
12574        }
12575        NodeContextPack::export_with_repository(
12576            ContextPackLineageReceipt {
12577                source_node_id: self.node_id.clone(),
12578                source_session: address.clone(),
12579                source_provider,
12580            },
12581            &loaded,
12582            Some(repository),
12583        )
12584        .map_err(|_| {
12585            failure(
12586                NodeFailureCode::ContextPackMaterializationFailed,
12587                "bounded context pack export failed",
12588            )
12589        })
12590    }
12591
12592    /// Write-through to the durable [`ContextPackStore`], if one is
12593    /// configured (`state_path` is `Some`). A node with no durable state
12594    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12595    /// open at all and this is a no-op, matching `persist_state`'s own
12596    /// "durability optional, degrade gracefully when unconfigured" idiom.
12597    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12598        let mut store = self
12599            .context_pack_store
12600            .lock()
12601            .unwrap_or_else(|poisoned| poisoned.into_inner());
12602        let Some(store) = store.as_mut() else {
12603            return Ok(());
12604        };
12605        store.commit(pack).map_err(|_| {
12606            failure(
12607                NodeFailureCode::ContextPackMaterializationFailed,
12608                "durable context pack commit failed",
12609            )
12610        })
12611    }
12612
12613    fn commit_context_pack(
12614        &self,
12615        pack: NodeContextPack,
12616    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12617        self.commit_context_pack_durably(&pack)?;
12618        self.context_catalog
12619            .write()
12620            .unwrap_or_else(|poisoned| poisoned.into_inner())
12621            .insert(pack)
12622            .map_err(|_| {
12623                failure(
12624                    NodeFailureCode::ContextPackMaterializationFailed,
12625                    "context pack catalog is full",
12626                )
12627            })
12628    }
12629
12630    fn commit_context_pack_for_session_record(
12631        &self,
12632        expected: &ManagedSessionRecord,
12633        identity: Option<&ProviderSessionIdentity>,
12634        session: &SessionAddress,
12635        candidate_id: &str,
12636        loaded: &HistorySessionRecord,
12637        pack: NodeContextPack,
12638        allow_clean_detachment: bool,
12639    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12640        let current_root = self.workspace_root(&expected.workspace_id)?;
12641        let _transaction = self
12642            .state_transaction
12643            .lock()
12644            .unwrap_or_else(|poisoned| poisoned.into_inner());
12645        let records = self
12646            .session_records
12647            .lock()
12648            .unwrap_or_else(|poisoned| poisoned.into_inner());
12649        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12650            failure(
12651                NodeFailureCode::SessionRecordConflict,
12652                "managed session record disappeared during context export",
12653            )
12654        })?;
12655        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12656            return Err(failure(
12657                NodeFailureCode::SessionRecordConflict,
12658                "managed session record changed during context export",
12659            ));
12660        }
12661        let bindings = self
12662            .session_bindings
12663            .lock()
12664            .unwrap_or_else(|poisoned| poisoned.into_inner());
12665        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12666            failure(
12667                NodeFailureCode::SessionRecordConflict,
12668                "managed session runtime binding disappeared during context export",
12669            )
12670        })?;
12671        let runtime = self
12672            .handle
12673            .snapshot()
12674            .sessions
12675            .iter()
12676            .find(|snapshot| {
12677                snapshot.instance_id == session.session.instance_id
12678                    && snapshot.generation == session.session.generation
12679            })
12680            .cloned()
12681            .ok_or_else(|| {
12682                failure(
12683                    NodeFailureCode::SessionRecordConflict,
12684                    "managed session runtime disappeared during context export",
12685                )
12686            })?;
12687        let receipt = pack.receipt();
12688        if !session_record_context_export_binding_is_exact(
12689            expected,
12690            current,
12691            identity,
12692            session,
12693            binding,
12694            &runtime.agent_id,
12695            &current_root,
12696            allow_clean_detachment,
12697        ) || !session_record_context_export_source_is_usable(
12698            current,
12699            &runtime.status,
12700            allow_clean_detachment,
12701        ) || runtime.history.pending.is_some()
12702            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12703            || runtime.history.loaded.as_ref() != Some(loaded)
12704            || receipt.lineage.source_node_id != self.node_id
12705            || receipt.lineage.source_session != *session
12706            || receipt.lineage.source_provider != current.provider
12707            || receipt.source_message_count != loaded.message_count
12708        {
12709            return Err(failure(
12710                NodeFailureCode::SessionRecordConflict,
12711                "managed session source changed before context catalog commit",
12712            ));
12713        }
12714        self.commit_context_pack_durably(&pack)?;
12715        self.context_catalog
12716            .write()
12717            .unwrap_or_else(|poisoned| poisoned.into_inner())
12718            .insert(pack)
12719            .map_err(|_| {
12720                failure(
12721                    NodeFailureCode::ContextPackMaterializationFailed,
12722                    "context pack catalog is full",
12723                )
12724            })
12725    }
12726
12727    async fn context_pack_repository(
12728        &self,
12729        workspace_id: &WorkspaceId,
12730    ) -> Result<ContextPackRepository, NodeFailure> {
12731        let first = self
12732            .context_pack_repository_observation(workspace_id)
12733            .await?;
12734        let second = self
12735            .context_pack_repository_observation(workspace_id)
12736            .await?;
12737        stable_context_pack_repository(first, second)
12738    }
12739
12740    async fn context_pack_repository_observation(
12741        &self,
12742        workspace_id: &WorkspaceId,
12743    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12744        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12745        let head = if git.is_repository {
12746            let root = self.workspace_root(workspace_id)?;
12747            match resolve_base_commit_with_timeout(
12748                &root,
12749                "HEAD",
12750                GIT_COMMAND_TIMEOUT_MS,
12751            )
12752            .await
12753            {
12754                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12755                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12756                    ContextPackRepositoryHead::Unborn
12757                }
12758                Err(_) => {
12759                    return Err(failure(
12760                        NodeFailureCode::ContextPackMaterializationFailed,
12761                        "context pack HEAD observation failed",
12762                    ));
12763                }
12764            }
12765        } else {
12766            ContextPackRepositoryHead::NotRepository
12767        };
12768        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12769        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12770            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12771                failure(
12772                    NodeFailureCode::InvalidRepositoryPath,
12773                    "context pack selected file path is invalid",
12774                )
12775            })?;
12776            match self.read_workspace_file(workspace_id.clone(), path).await {
12777                Ok(WorkspaceFileRead {
12778                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12779                    ..
12780                }) => files.push(ContextPackRepositoryFileSource::utf8(
12781                    selected_path,
12782                    text,
12783                    byte_len,
12784                )),
12785                Ok(WorkspaceFileRead {
12786                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12787                    ..
12788                }) => files.push(ContextPackRepositoryFileSource::skipped(
12789                    selected_path,
12790                    ContextPackSelectedFileSkipReason::NonUtf8,
12791                    Some(byte_len),
12792                )),
12793                Ok(WorkspaceFileRead {
12794                    content: WorkspaceFileContent::TooLarge { .. },
12795                    ..
12796                }) => files.push(ContextPackRepositoryFileSource::skipped(
12797                    selected_path,
12798                    ContextPackSelectedFileSkipReason::TooLarge,
12799                    None,
12800                )),
12801                Err(error) => {
12802                    let reason = if matches!(
12803                        error.code,
12804                        NodeFailureCode::InvalidRepositoryPath
12805                            | NodeFailureCode::RepositoryFileNotRegular
12806                            | NodeFailureCode::RepositoryPathUnsafe
12807                    ) {
12808                        ContextPackSelectedFileSkipReason::Unsafe
12809                    } else {
12810                        ContextPackSelectedFileSkipReason::Unavailable
12811                    };
12812                    files.push(ContextPackRepositoryFileSource::skipped(
12813                        selected_path,
12814                        reason,
12815                        None,
12816                    ));
12817                }
12818            }
12819        }
12820        Ok(ContextPackRepositoryObservation { head, git, files })
12821    }
12822
12823    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12824        let referenced_by_binding = self
12825            .session_bindings
12826            .lock()
12827            .unwrap_or_else(|poisoned| poisoned.into_inner())
12828            .values()
12829            .any(|binding| {
12830                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12831            });
12832        let referenced_by_record = self
12833            .session_records
12834            .lock()
12835            .unwrap_or_else(|poisoned| poisoned.into_inner())
12836            .records
12837            .values()
12838            .any(|record| record.context_id.as_ref() == Some(context_id));
12839        let referenced_by_materialization = self
12840            .materializations
12841            .lock()
12842            .unwrap_or_else(|poisoned| poisoned.into_inner())
12843            .values()
12844            .any(|ownership| {
12845                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12846            });
12847        if referenced_by_binding
12848            || referenced_by_record
12849            || referenced_by_materialization
12850        {
12851            return Err(failure(
12852                NodeFailureCode::ContextPackBusy,
12853                "context pack is still referenced",
12854            ));
12855        }
12856        let mut catalog = self.context_catalog
12857            .write()
12858            .unwrap_or_else(|poisoned| poisoned.into_inner());
12859        if catalog.get(context_id).is_none() {
12860            return Err(failure(
12861                NodeFailureCode::UnknownContextPack,
12862                "context pack is unavailable",
12863            ));
12864        }
12865        self.spawn_idempotency
12866            .lock()
12867            .unwrap_or_else(|poisoned| poisoned.into_inner())
12868            .entries
12869            .retain(|_, entry| !entry.value.references_context(context_id));
12870        let removed = catalog.remove(context_id);
12871        debug_assert!(removed.is_some(), "validated context pack remains present");
12872        Ok(())
12873    }
12874
12875    async fn dispatch_input_bounded(
12876        &self,
12877        address: &SessionAddress,
12878        action: InputAction,
12879    ) -> Result<(), NodeFailure> {
12880        let started_after = self.current_sequence();
12881        self.pty_input_marks
12882            .lock()
12883            .unwrap_or_else(|poisoned| poisoned.into_inner())
12884            .insert(address.session.instance_id, (Instant::now(), false));
12885        self.pty_input_wake.notify_one();
12886        let command_id = self
12887            .dispatch_bounded(
12888                ControlCommand::SendInput {
12889                    instance_id: address.session.instance_id,
12890                    action,
12891                },
12892                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893            )
12894            .await?;
12895        // A semantic prompt may spend the provider's full readiness budget before
12896        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12897        // but strictly beyond every readiness policy in this node's catalog.
12898        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12899        let mut scan_after = started_after;
12900        let mut accepted_after = None;
12901        loop {
12902            let events = self
12903                .history
12904                .lock()
12905                .unwrap_or_else(|poisoned| poisoned.into_inner())
12906                .events
12907                .iter()
12908                .filter(|envelope| envelope.sequence > scan_after)
12909                .cloned()
12910                .collect::<Vec<_>>();
12911            for envelope in events {
12912                scan_after = scan_after.max(envelope.sequence);
12913                let NodeEvent::Control {
12914                    address: event_address,
12915                    event,
12916                } = envelope.event
12917                else {
12918                    continue;
12919                };
12920                if event_address != *address {
12921                    continue;
12922                }
12923                if event.command_id == Some(command_id) {
12924                    match &event.event {
12925                        ControlEventKind::CommandRejected { message } => {
12926                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12927                        }
12928                        ControlEventKind::InputRequested { .. } => {
12929                            accepted_after = Some(envelope.sequence);
12930                            continue;
12931                        }
12932                        _ => {}
12933                    }
12934                }
12935                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12936                    match &event.event {
12937                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12938                        ControlEventKind::InputFailed { message, .. } => {
12939                            return Err(failure(
12940                                NodeFailureCode::BackendOperationFailed,
12941                                message,
12942                            ));
12943                        }
12944                        _ => {}
12945                    }
12946                }
12947            }
12948            if Instant::now() >= deadline {
12949                return Err(failure(
12950                    NodeFailureCode::BackendBusy,
12951                    if accepted_after.is_some() {
12952                        "input was accepted but did not settle before the bounded deadline"
12953                    } else {
12954                        "input was not accepted before the bounded deadline"
12955                    },
12956                ));
12957            }
12958            sleep(Duration::from_millis(2)).await;
12959        }
12960    }
12961
12962    async fn dispatch_resize_bounded(
12963        &self,
12964        address: &SessionAddress,
12965        size: gate4agent_types::TerminalSize,
12966    ) -> Result<(), NodeFailure> {
12967        let started_after = self.current_sequence();
12968        let command_id = self
12969            .dispatch_bounded(
12970                ControlCommand::Resize {
12971                    instance_id: address.session.instance_id,
12972                    size,
12973                },
12974                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12975            )
12976            .await?;
12977        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12978        let mut scan_after = started_after;
12979        let mut accepted_after = None;
12980        loop {
12981            let events = self
12982                .history
12983                .lock()
12984                .unwrap_or_else(|poisoned| poisoned.into_inner())
12985                .events
12986                .iter()
12987                .filter(|envelope| envelope.sequence > scan_after)
12988                .cloned()
12989                .collect::<Vec<_>>();
12990            for envelope in events {
12991                scan_after = scan_after.max(envelope.sequence);
12992                let NodeEvent::Control {
12993                    address: event_address,
12994                    event,
12995                } = envelope.event
12996                else {
12997                    continue;
12998                };
12999                if event_address != *address {
13000                    continue;
13001                }
13002                if event.command_id == Some(command_id) {
13003                    match &event.event {
13004                        ControlEventKind::CommandRejected { message } => {
13005                            return Err(failure(NodeFailureCode::InvalidRequest, message));
13006                        }
13007                        ControlEventKind::ResizeRequested {
13008                            size: requested_size,
13009                            ..
13010                        } if *requested_size == size => {
13011                            accepted_after = Some(envelope.sequence);
13012                            continue;
13013                        }
13014                        _ => {}
13015                    }
13016                }
13017                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13018                    match &event.event {
13019                        ControlEventKind::Resized { size: settled_size }
13020                            if *settled_size == size =>
13021                        {
13022                            return Ok(());
13023                        }
13024                        ControlEventKind::ResizeFailed { message } => {
13025                            return Err(failure(
13026                                NodeFailureCode::BackendOperationFailed,
13027                                message,
13028                            ));
13029                        }
13030                        _ => {}
13031                    }
13032                }
13033            }
13034            if Instant::now() >= deadline {
13035                return Err(failure(
13036                    NodeFailureCode::BackendBusy,
13037                    if accepted_after.is_some() {
13038                        "resize was accepted but did not settle before the bounded deadline"
13039                    } else {
13040                        "resize was not accepted before the bounded deadline"
13041                    },
13042                ));
13043            }
13044            sleep(Duration::from_millis(2)).await;
13045        }
13046    }
13047
13048    async fn spawn_session(
13049        &self,
13050        workspace_id: WorkspaceId,
13051        provider: AgentId,
13052        mode: SessionMode,
13053        terminal_size: gate4agent_types::TerminalSize,
13054        initial_prompt: Option<String>,
13055    ) -> Result<SessionAddress, NodeFailure> {
13056        if self.managed_worktrees.lock()
13057            .unwrap_or_else(|poisoned| poisoned.into_inner())
13058            .lease_for_workspace(&workspace_id)
13059            .is_some()
13060        {
13061            return Err(failure(
13062                NodeFailureCode::ManagedWorktreeOwnershipConflict,
13063                "managed worktree targets may only be spawned through their lease request",
13064            ));
13065        }
13066        self
13067            .spawn_session_with_deadline(
13068                workspace_id,
13069                provider.clone(),
13070                mode,
13071                terminal_size,
13072                initial_prompt,
13073                None,
13074                None,
13075                None,
13076                None,
13077                None,
13078                SpawnRecordPolicy::ProviderIdentityOnly,
13079                None,
13080                &[],
13081                None,
13082                // `NodeRequest::Spawn` (this method's only caller) carries no
13083                // approval-level selection of its own -- see
13084                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13085                // the one operator-wire path that can choose something other
13086                // than the axis default. Auto-permit may lower it where the agent
13087                // cannot offer its full-auto mode (claude ACP under root).
13088                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13089                None,
13090                None,
13091            )
13092            .await
13093            .map(|(session, _runtime_policy)| session)
13094    }
13095
13096    async fn spawn_session_with_deadline(
13097        &self,
13098        workspace_id: WorkspaceId,
13099        provider: AgentId,
13100        mode: SessionMode,
13101        terminal_size: gate4agent_types::TerminalSize,
13102        initial_prompt: Option<String>,
13103        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13104        bundle: Option<ResolvedBundleReceipt>,
13105        context: Option<ResolvedContextPackReceipt>,
13106        managed_authority: Option<ManagedWorktreeLeaseId>,
13107        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13108        record_policy: SpawnRecordPolicy,
13109        deadline: Option<Instant>,
13110        required_capabilities: &[ProviderRuntimeCapability],
13111        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13112        approval_level: ApprovalLevel,
13113        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13114        // Opaque dig2browser station profile id (ids only). Feature
13115        // `dig2-station-probe`: after probe at resolve, spawn takes an
13116        // exclusive node-local lease; cleared on session end. Never
13117        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13118        // already happened on the receipt path).
13119        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13120    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13121        let reserved_lease = self.managed_worktrees.lock()
13122            .unwrap_or_else(|poisoned| poisoned.into_inner())
13123            .lease_for_workspace(&workspace_id);
13124        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13125            (Some(expected), Some(provided)) if expected == provided => {}
13126            (None, None) => {}
13127            _ => {
13128                return Err(failure(
13129                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13130                    "spawn lacks exact managed worktree lease authority",
13131                ));
13132            }
13133        }
13134        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13135        // bindings) on the same workspace cwd. Parallel path: managed worktree
13136        // spawn so each profile gets its own workspace_id.
13137        self.ensure_workspace_environment_profile_compatible(
13138            &workspace_id,
13139            environment_profile.as_ref(),
13140        )?;
13141        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13142            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13143            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13144            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13145            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13146        };
13147        // This session's own transport -- computed once, here, from `mode`,
13148        // and reused both to admit spawn (below) and, further down, to
13149        // compute the final `runtime_policy` this session actually operates
13150        // under (`provider_runtime::policy_for_transport`). Registration
13151        // reuses this exact value rather than recomputing it a second time.
13152        let transport = match mode {
13153            SessionMode::Pty => TransportKind::Pty,
13154            SessionMode::Inline => TransportKind::Pipe,
13155            SessionMode::Acp => TransportKind::Acp,
13156        };
13157        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13158        let acp_transport = self.provider_supports_acp_transport(&provider);
13159        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13160            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13161                NodeFailureCode::UnsupportedSpawnCapability,
13162                "pre-admitted provider runtime no longer satisfies spawn requirements",
13163            ))?;
13164            runtime_policy
13165        } else if let Some(deadline) = deadline {
13166            let remaining = spawn_deadline_remaining(deadline)?;
13167            timeout(
13168                remaining,
13169                self.admit_provider_runtime(&provider, runtime_requirement),
13170            )
13171            .await
13172            .map_err(|_| {
13173                failure(
13174                    NodeFailureCode::SpawnDeadlineExceeded,
13175                    "provider admission exceeded the spawn deadline",
13176                )
13177            })??
13178        } else {
13179            self.admit_provider_runtime(&provider, runtime_requirement).await?
13180        };
13181        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13182        // The single point where this session's `runtime_policy` becomes
13183        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13184        // branch above) only ever compute a PTY-terminal-verification-shaped
13185        // value, sound outright only for `TransportKind::Pty` (Pipe still
13186        // relies on that same value downstream -- see `policy_for_transport`'s
13187        // own doc for why). `policy_for_transport` is the explicit,
13188        // exhaustive match Item 1 asks for -- this is the ONE place its
13189        // output becomes the session's `runtime_policy`, so every later
13190        // reader (`ControlCommand::Start`, `session_bindings`, and every
13191        // engine gate that reads `state.runtime_policy`) sees the same,
13192        // correctly-computed value.
13193        let runtime_policy =
13194            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13195        if required_capabilities
13196            .iter()
13197            .any(|capability| !runtime_policy.admits(*capability))
13198        {
13199            return Err(failure(
13200                NodeFailureCode::UnsupportedSpawnCapability,
13201                "provider runtime does not admit a required spawn capability",
13202            ));
13203        }
13204        if let Some(deadline) = deadline {
13205            spawn_deadline_remaining(deadline)?;
13206        }
13207        self.ensure_binding_capacity()?;
13208        let mut working_directory = self.workspace_root(&workspace_id)?;
13209        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13210        let session = SessionKey {
13211            instance_id,
13212            generation: SessionGeneration(1),
13213        };
13214        let address = SessionAddress {
13215            workspace_id: workspace_id.clone(),
13216            session,
13217        };
13218        // This is the request-accepted checkpoint: admission (managed
13219        // worktree authority, runtime capability, dispatch deadline) already
13220        // passed and a concrete session identity is minted. It is a distinct
13221        // state from the process actually running (`ControlEventKind::Running`,
13222        // logged in `publish_control`) or the terminal outcome (`Exited`/
13223        // `Failed`, logged there too) -- this line exists so that gap is no
13224        // longer silent.
13225        tracing::info!(
13226            node_id = %self.node_id,
13227            workspace_id = %workspace_id,
13228            provider = %provider,
13229            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13230            session = ?address.session,
13231            "session spawn request accepted",
13232        );
13233        let mut prepared_spawn = SpawnPreparer::prepare(
13234            self,
13235            SpawnPrepareRequest {
13236                instance_id,
13237                address: &address,
13238                provider: &provider,
13239                mode,
13240                environment_profile: environment_profile.as_ref(),
13241                bundle: bundle.as_ref(),
13242                context: context.as_ref(),
13243                managed_authority: managed_authority.as_ref(),
13244                harness_mcp,
13245                approval_level,
13246                network_allowlist: network_allowlist.as_ref(),
13247                browser_profile_id,
13248            },
13249        )?;
13250        let materialization_id = prepared_spawn.materialization_id.take();
13251        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13252        let record_id = self.bind_spawn_session_with_materialization(
13253            &address,
13254            provider.clone(),
13255            mode,
13256            runtime_policy,
13257            record_policy,
13258            environment_profile,
13259            bundle,
13260            context,
13261            materialization_id,
13262        )?;
13263        prepared_spawn.retain_session_guards();
13264        // `transport` was already computed above, alongside this session's
13265        // `runtime_policy`; reused here for `Register` rather than derived a
13266        // second time.
13267        let session_provider = provider.clone();
13268        let agent_id = provider;
13269        let register_command_id = match self.dispatch_bounded(
13270            ControlCommand::Register {
13271                instance_id,
13272                agent_id,
13273                transport,
13274            },
13275            dispatch_timeout,
13276        )
13277        .await {
13278            Ok(command_id) => command_id,
13279            Err(error) => {
13280                if let Some(binding) = self.remove_binding(&address) {
13281                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13282                }
13283                if let Some(record_id) = record_id.as_ref() {
13284                    self.discard_record(record_id)?;
13285                }
13286                return Err(spawn_dispatch_error(error, deadline));
13287            }
13288        };
13289        let start_timeout = match spawn_dispatch_timeout(deadline) {
13290            Ok(timeout) => timeout,
13291            Err(error) => {
13292                let recovery = self
13293                    .rollback_spawn(
13294                        &address,
13295                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13296                    )
13297                    .await;
13298                return match recovery {
13299                    Ok(()) => {
13300                        if let Some(record_id) = record_id.as_ref() {
13301                            self.discard_record(record_id)?;
13302                        }
13303                        Err(error)
13304                    }
13305                    Err(recovery_error) => {
13306                        if let Some(record_id) = record_id.as_ref() {
13307                            self.mark_record_error(
13308                                record_id,
13309                                "spawn deadline elapsed and registration recovery failed",
13310                            )?;
13311                        }
13312                        Err(recovery_error)
13313                    }
13314                };
13315            }
13316        };
13317        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13318            if let Err(recovery_error) = self
13319                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13320                .await
13321            {
13322                return Err(recovery_error);
13323            }
13324            if let Some(record_id) = record_id.as_ref() {
13325                self.discard_record(record_id)?;
13326            }
13327            return Err(start_error);
13328        }
13329        // A session with its own m4a account runs in its own folder inside the workspace.
13330        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13331            working_directory = dir.to_string_lossy().into_owned();
13332        }
13333        let start_result = self
13334            .dispatch_bounded(
13335                ControlCommand::Start {
13336                    instance_id,
13337                    runtime_policy,
13338                    request: StartRequest {
13339                        working_directory,
13340                        terminal_size,
13341                        initial_prompt,
13342                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13343                        approval_level,
13344                    },
13345                },
13346                start_timeout,
13347            )
13348            .await;
13349        let start_command_id = match start_result {
13350            Ok(command_id) => command_id,
13351            Err(start_error) => {
13352                let start_error = spawn_dispatch_error(start_error, deadline);
13353                let recovery = self
13354                    .rollback_spawn(
13355                        &address,
13356                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13357                    )
13358                    .await;
13359                return match recovery {
13360                    Ok(()) => {
13361                        if let Some(record_id) = record_id.as_ref() {
13362                            self.discard_record(record_id)?;
13363                        }
13364                        Err(start_error)
13365                    }
13366                    Err(recovery_error) => {
13367                        let diagnostic = format!(
13368                            "start failed and registration recovery failed: {}",
13369                            recovery_error.message,
13370                        );
13371                        if let Some(record_id) = record_id.as_ref() {
13372                            self.mark_record_error(record_id, &diagnostic)?;
13373                        }
13374                        Err(failure(recovery_error.code, &diagnostic))
13375                    }
13376                };
13377            }
13378        };
13379
13380        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13381        loop {
13382            // `Register`/`Start` only fail synchronously for a port-level
13383            // ingress problem (see `dispatch_bounded`); the kernel refusing
13384            // either command's content -- an unsupported transport, an
13385            // unknown agent -- lands one drive-loop tick later, and no
13386            // session ever appears in `self.handle.snapshot()` for it to
13387            // notice below. Check for that named reason before falling
13388            // through to the blind presence poll, so a rejected spawn
13389            // reports why instead of just timing out.
13390            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13391                .or_else(|| self.take_rejected_command_reason(start_command_id))
13392            {
13393                self.rollback_spawn(
13394                    &address,
13395                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13396                ).await.map_err(|error| {
13397                        failure(
13398                            error.code,
13399                            &format!(
13400                                "spawn was rejected ({}) and registration recovery failed: {}",
13401                                reason.message, error.message
13402                            ),
13403                        )
13404                    })?;
13405                if let Some(record_id) = record_id.as_ref() {
13406                    self.discard_record(record_id)?;
13407                }
13408                let code = if reason.unsupported_transport {
13409                    NodeFailureCode::UnsupportedTransport
13410                } else {
13411                    NodeFailureCode::BackendOperationFailed
13412                };
13413                return Err(failure(code, &reason.message));
13414            }
13415            if let Some(current) = self
13416                .handle
13417                .snapshot()
13418                .sessions
13419                .iter()
13420                .find(|current| current.instance_id == instance_id)
13421            {
13422                if current.generation != session.generation {
13423                    let divergence = failure(
13424                        NodeFailureCode::StaleGeneration,
13425                        "spawn generation diverged",
13426                    );
13427                    return match self
13428                        .rollback_spawn(
13429                            &address,
13430                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13431                        )
13432                        .await
13433                    {
13434                        Ok(()) => {
13435                            if let Some(record_id) = record_id.as_ref() {
13436                                self.discard_record(record_id)?;
13437                            }
13438                            Err(divergence)
13439                        }
13440                        Err(recovery_error) => {
13441                            if let Some(record_id) = record_id.as_ref() {
13442                                self.mark_record_error(
13443                                    record_id,
13444                                    "spawn generation diverged and recovery failed",
13445                                )?;
13446                            }
13447                            Err(recovery_error)
13448                        }
13449                    };
13450                }
13451                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13452                    prepared_spawn.retain_launch_guards();
13453                    return Ok((address, runtime_policy));
13454                }
13455            }
13456            if Instant::now() >= commit_deadline {
13457                self.rollback_spawn(
13458                    &address,
13459                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13460                ).await.map_err(|error| {
13461                        failure(
13462                            error.code,
13463                            &format!(
13464                                "start did not commit and registration recovery failed: {}",
13465                                error.message
13466                            ),
13467                        )
13468                    })?;
13469                if let Some(record_id) = record_id.as_ref() {
13470                    self.discard_record(record_id)?;
13471                }
13472                return Err(failure(
13473                    if deadline.is_some() {
13474                        NodeFailureCode::SpawnDeadlineExceeded
13475                    } else {
13476                        NodeFailureCode::BackendBusy
13477                    },
13478                    "start did not commit before the bounded deadline; registration was removed",
13479                ));
13480            }
13481            sleep(Duration::from_millis(2)).await;
13482        }
13483    }
13484
13485    fn effective_spawn_runtime_policy(
13486        &self,
13487        runtime_policy: ProviderRuntimePolicy,
13488    ) -> ProviderRuntimePolicy {
13489        #[cfg(feature = "fixture")]
13490        let runtime_policy = if self.fixture_semantic_hook_policy {
13491            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13492                .expect("monitoring Hook fixture policy is internally valid")
13493        } else {
13494            runtime_policy
13495        };
13496        runtime_policy
13497    }
13498
13499    fn verify_harness_mcp_before_start(
13500        &self,
13501        prepared: Option<&PreparedHarnessMcpSpawn>,
13502    ) -> Result<(), NodeFailure> {
13503        prepared
13504            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13505            .transpose()
13506            .map(|_| ())
13507            .map_err(harness_mcp_failure)
13508    }
13509
13510    async fn rollback_spawn(
13511        &self,
13512        address: &SessionAddress,
13513        commit_timeout: Duration,
13514    ) -> Result<(), NodeFailure> {
13515        self.dispatch_bounded(
13516            ControlCommand::Remove {
13517                instance_id: address.session.instance_id,
13518            },
13519            commit_timeout,
13520        )
13521        .await?;
13522        self.wait_until_removed(address, commit_timeout).await?;
13523        if let Some(binding) = self.remove_binding(address) {
13524            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13525        }
13526        Ok(())
13527    }
13528
13529    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13530        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13531        self.dispatch_bounded(
13532            ControlCommand::Remove {
13533                instance_id: address.session.instance_id,
13534            },
13535            commit_timeout,
13536        )
13537            .await?;
13538        self.wait_until_removed(address, commit_timeout).await?;
13539        let removed = self.remove_binding(address);
13540        if let Some(binding) = removed.as_ref() {
13541            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13542        }
13543        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13544            let snapshot = self.managed_worktrees.lock()
13545                .unwrap_or_else(|poisoned| poisoned.into_inner())
13546                .release_session(
13547                    &lease_id,
13548                    self.incarnation_id,
13549                    address.session.instance_id,
13550                    address.session.generation,
13551                    unix_time_ms(),
13552                );
13553            if self.persist_state().is_err() {
13554                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13555            }
13556            if let Some(snapshot) = snapshot {
13557                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13558            }
13559            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13560        }
13561        Ok(())
13562    }
13563
13564    /// Polls until `address`'s session leaves the engine's own session map,
13565    /// or the bounded deadline expires.
13566    ///
13567    /// A `Remove` sent while the session is not yet in a terminal status
13568    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13569    /// once by the engine (`SessionStatus::allows_remove`) and
13570    /// then discarded -- nothing re-issues it on its own, so the removal
13571    /// that would satisfy this wait never arrives and the caller was
13572    /// guaranteed to time out even though the session settles microseconds
13573    /// later. So this re-dispatches on every poll tick: a session that
13574    /// settles mid-wait -- the common case right after a `Stop` -- still
13575    /// gets removed inside the same bounded deadline.
13576    ///
13577    /// It re-dispatches only once the status will ACCEPT the command. A
13578    /// rejected re-dispatch is not the harmless no-op this loop first
13579    /// assumed: every rejection publishes a `ControlEventKind::Command
13580    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13581    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13582    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13583    /// out to the harness and every attached client. Gated, the same
13584    /// teardown sends exactly one `Remove`, at the first tick the engine
13585    /// can act on it.
13586    ///
13587    /// The status is read from the snapshot this loop already takes, so
13588    /// the gate costs nothing extra. It races -- the status can move
13589    /// between the read and the dispatch -- and that is fine: a rare lost
13590    /// race is one rejection, and the next tick retries.
13591    ///
13592    /// Re-dispatch is separately rate-limited to
13593    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13594    /// leaves a second burst behind it. Once the session settles, every
13595    /// 2ms tick sent another `Remove` while the accepted one was still in
13596    /// flight, and each of those landed on an instance the engine had
13597    /// already dropped -- `agent instance … is not registered`, observed
13598    /// live as a tail of rejections immediately after the `session
13599    /// removed` line. The presence poll stays at 2ms so the wait still
13600    /// exits promptly; only the sending slows down.
13601    ///
13602    /// Rate-limited rather than once-only on purpose: the status gate
13603    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13604    /// others (a pending operation, an in-flight capability probe or
13605    /// history request) that clear on their own. A single shot would turn
13606    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13607    /// get their retries, just twenty of them across the deadline instead
13608    /// of a thousand.
13609    async fn wait_until_removed(
13610        &self,
13611        address: &SessionAddress,
13612        commit_timeout: Duration,
13613    ) -> Result<(), NodeFailure> {
13614        let deadline = Instant::now() + commit_timeout;
13615        let mut next_dispatch = Instant::now();
13616        loop {
13617            let snapshot = self.handle.snapshot();
13618            let status = snapshot
13619                .sessions
13620                .iter()
13621                .find(|session| session.instance_id == address.session.instance_id)
13622                .map(|session| session.status.clone());
13623            let Some(status) = status else {
13624                return Ok(());
13625            };
13626            let now = Instant::now();
13627            if now >= deadline {
13628                return Err(failure(
13629                    NodeFailureCode::BackendBusy,
13630                    "remove did not commit before the bounded deadline; session binding was retained",
13631                ));
13632            }
13633            if status.allows_remove() && now >= next_dispatch {
13634                let _ = self.dispatch(ControlCommand::Remove {
13635                    instance_id: address.session.instance_id,
13636                });
13637                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13638            }
13639            sleep(Duration::from_millis(2)).await;
13640        }
13641    }
13642
13643    fn resync(&self, after_sequence: u64) -> NodeResponse {
13644        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13645        let event_sequence = history.last_sequence;
13646        let oldest_available_sequence = history.replay_floor_sequence;
13647        let events = history
13648            .events
13649            .iter()
13650            .filter(|event| event.sequence > after_sequence)
13651            .cloned()
13652            .collect();
13653        drop(history);
13654        NodeResponse::Resync {
13655            event_sequence,
13656            oldest_available_sequence,
13657            snapshot: self.snapshot(),
13658            events,
13659        }
13660    }
13661}
13662
13663/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13664/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13665/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13666/// "name what crossed this boundary" reasoning, one layer upstream of it).
13667fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13668    match kind {
13669        AgentStreamChunkKindV1::Text { .. } => "text",
13670        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13671        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13672        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13673        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13674        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13675        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13676    }
13677}
13678
13679/// Serves this wire on the local endpoint, and -- when `call_home` names a
13680/// relay -- over an outbound connection to it as well.
13681///
13682/// The two live in one future rather than a fourth arm of `run`'s own
13683/// `select!` because they are one concern: this is where the node's wire
13684/// is served, and the direction a given connection was opened in does not
13685/// change what happens on it. It also means the existing graceful drain
13686/// below covers both without being duplicated.
13687///
13688/// Either side finishing ends the pair, which is correct: the listener
13689/// only returns on shutdown or a bind failure, and `call_home_loop` only
13690/// returns on shutdown -- a failed relay connection is retried inside it,
13691/// never propagated out.
13692async fn accept_connections(
13693    endpoint: &str,
13694    call_home: Option<std::net::SocketAddr>,
13695    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13696    shared: Arc<NodeShared>,
13697) -> Result<(), NodeServerError> {
13698    let mut connections = JoinSet::new();
13699    let result = match call_home {
13700        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13701        Some(relay) => {
13702            let listener =
13703                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13704            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13705            tokio::pin!(listener);
13706            tokio::pin!(dialler);
13707            tokio::select! {
13708                result = &mut listener => result,
13709                result = &mut dialler => result,
13710            }
13711        }
13712    };
13713    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13714    while !connections.is_empty() {
13715        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13716        if remaining.is_zero()
13717            || timeout(remaining, connections.join_next()).await.is_err()
13718        {
13719            break;
13720        }
13721    }
13722    connections.abort_all();
13723    while connections.join_next().await.is_some() {}
13724    result
13725}
13726
13727/// Backoff ladder for a relay that will not answer, capped rather than
13728/// unbounded: a node whose relay is down is not broken and should keep
13729/// trying forever, but it should not spend a core doing it.
13730const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13731
13732/// Dials the relay, serves this wire over the connection, and does it
13733/// again when the connection ends.
13734///
13735/// The node is the SERVER on the socket it just opened. That inversion is
13736/// the whole point: `serve_connection` is the same function the local
13737/// listener feeds, and it neither knows nor cares which end placed the
13738/// call. The one thing this direction adds is the preface -- the relay
13739/// accepted a socket and cannot know whose it is, so the node says its
13740/// name before the handshake it would otherwise begin with.
13741///
13742/// Never returns an error. A relay that is down, refusing, or speaking a
13743/// different protocol is a condition to retry, not a reason to take the
13744/// node's local endpoint down with it; the only way out is shutdown.
13745async fn call_home_loop(
13746    relay: std::net::SocketAddr,
13747    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13748    shared: Arc<NodeShared>,
13749) -> Result<(), NodeServerError> {
13750    let mut failures = 0_usize;
13751    loop {
13752        if shared.shutdown.load(Ordering::Acquire) {
13753            return Ok(());
13754        }
13755        let permit = tokio::select! {
13756            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13757                permit.map_err(|_| NodeServerError::PreauthClosed)?
13758            }
13759            _ = shared.shutdown_notify.notified() => return Ok(()),
13760        };
13761        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13762            Ok(()) => {
13763                // A clean end is the relay hanging up, which it does on
13764                // its own restart. Reconnect immediately rather than
13765                // serving the backoff a peer that is plainly there.
13766                failures = 0;
13767                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13768            }
13769            Err(error) => {
13770                failures = failures.saturating_add(1);
13771                tracing::warn!(
13772                    node_id = %shared.node_id,
13773                    %relay,
13774                    cause = %error,
13775                    attempt = failures,
13776                    "call-home connection failed",
13777                );
13778                let backoff = call_home_backoff(failures);
13779                tokio::select! {
13780                    _ = sleep(backoff) => {}
13781                    _ = shared.shutdown_notify.notified() => return Ok(()),
13782                }
13783            }
13784        }
13785    }
13786}
13787
13788/// Idle time before the first keepalive probe on a call-home socket.
13789const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13790/// Interval between keepalive probes once the link has gone quiet.
13791const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13792
13793fn call_home_keepalive() -> socket2::TcpKeepalive {
13794    let keepalive = socket2::TcpKeepalive::new()
13795        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13796        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13797    #[cfg(not(windows))]
13798    let keepalive = keepalive.with_retries(3);
13799    keepalive
13800}
13801
13802fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13803    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13804}
13805
13806/// Same keepalive profile for any node->relay socket (restream uplink).
13807pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13808    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13809}
13810
13811/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13812fn call_home_backoff(attempt: usize) -> Duration {
13813    Duration::from_millis(
13814        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13815    )
13816}
13817
13818
13819/// Dial the call-home relay. Tunnel addresses go through the in-process
13820/// WireGuard dialer when the userspace session is up.
13821async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13822    #[cfg(feature = "wireguard")]
13823    {
13824        let std_stream = tokio::task::spawn_blocking(move || {
13825            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13826        })
13827        .await
13828        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13829        .map_err(NodeServerError::CallHomeTransport)?;
13830        std_stream
13831            .set_nonblocking(true)
13832            .map_err(NodeServerError::CallHomeTransport)?;
13833        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13834    }
13835    #[cfg(not(feature = "wireguard"))]
13836    {
13837        tokio::net::TcpStream::connect(relay)
13838            .await
13839            .map_err(NodeServerError::CallHomeTransport)
13840    }
13841}
13842
13843/// One call-home attempt: connect, name this node, then serve.
13844async fn dial_relay_and_serve(
13845    relay: std::net::SocketAddr,
13846    identity: Option<&hatchery_enroll::NodeIdentity>,
13847    shared: Arc<NodeShared>,
13848    preauth_permit: OwnedSemaphorePermit,
13849) -> Result<(), NodeServerError> {
13850    let mut stream = dial_call_home(relay).await?;
13851    // Nagle off for the same reason the rest of this wire wants it off:
13852    // the traffic is small request/response frames, and coalescing them
13853    // adds latency to every one.
13854    let _ = stream.set_nodelay(true);
13855    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13856    // relay-side socket kill whose RST was lost) would otherwise leave this
13857    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13858    // fails and the reconnect loop never runs. Kernel keepalive turns that
13859    // half-open link into a read error within a bounded time.
13860    if let Err(error) = enable_call_home_keepalive(&stream) {
13861        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13862    }
13863    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13864        .await
13865        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13866    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13867    let mut session_token = None;
13868    if let Some(identity) = identity {
13869        // An enrolled relay answers the announce with a signed-call-home challenge; a
13870        // token-mode relay sends its wire hello instead. Peek to tell them apart
13871        // without consuming either.
13872        let mut magic = [0_u8; 4];
13873        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13874            .await
13875            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13876            .map_err(NodeServerError::CallHomeTransport)?;
13877        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13878            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13879            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13880                .await
13881                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13882                .map_err(NodeServerError::CallHomeTransport)?;
13883            let (answer, token) = identity
13884                .answer_challenge(shared.node_id.as_str(), &frame)
13885                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13886            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13887            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13888            session_token = Some(token);
13889        }
13890    }
13891    serve_connection(stream, shared, session_token, preauth_permit).await
13892}
13893
13894async fn accept_connections_inner(
13895    endpoint: &str,
13896    shared: Arc<NodeShared>,
13897    connections: &mut JoinSet<Result<(), NodeServerError>>,
13898) -> Result<(), NodeServerError> {
13899    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13900    loop {
13901        while connections.try_join_next().is_some() {}
13902        if shared.shutdown.load(Ordering::Acquire) {
13903            return Ok(());
13904        }
13905        let preauth_permit = tokio::select! {
13906            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13907                permit.map_err(|_| NodeServerError::PreauthClosed)?
13908            }
13909            _ = shared.shutdown_notify.notified() => return Ok(()),
13910        };
13911        let server = tokio::select! {
13912            result = listener.accept() => result?,
13913            _ = shared.shutdown_notify.notified() => return Ok(()),
13914        };
13915        let connection_shared = Arc::clone(&shared);
13916        connections.spawn(async move {
13917            serve_connection(server, connection_shared, None, preauth_permit).await
13918        });
13919        if shared.shutdown.load(Ordering::Acquire) {
13920            return Ok(());
13921        }
13922    }
13923}
13924
13925/// Serves one connection, whichever end opened it.
13926///
13927/// Generic over the stream rather than typed to `LocalServerStream`
13928/// because a call-home connection arrives on a `TcpStream` the node itself
13929/// dialled. Nothing in the body ever depended on the concrete type -- it
13930/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13931/// the whole of what welding "who dials" to "who serves" was costing.
13932async fn serve_connection<S>(
13933    mut pipe: S,
13934    shared: Arc<NodeShared>,
13935    session_token: Option<String>,
13936    preauth_permit: OwnedSemaphorePermit,
13937) -> Result<(), NodeServerError>
13938where
13939    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13940{
13941    // A signed call-home connection authenticates with its own per-connection token.
13942    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13943    let hello_frame = timeout(
13944        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13945        read_json_frame_limited_body_timeout(
13946            &mut pipe,
13947            MAX_NODE_HELLO_FRAME_BYTES,
13948            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13949        ),
13950    )
13951    .await
13952    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13953    let ClientFrame::Hello(hello) = hello_frame else {
13954        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13955    };
13956    if hello.build_stamp != BUILD_STAMP {
13957        return Err(NodeServerError::Handshake(format!(
13958            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13959            hello.build_stamp,
13960        )));
13961    }
13962    let compatibility = match hello.compatibility.as_ref() {
13963        Some(offer) => {
13964            let mut selected = node_compatibility_support(&shared)?
13965                .negotiate(offer)
13966                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13967            project_negotiated_provider_ids(&mut selected);
13968            Some(selected)
13969        }
13970        None => None,
13971    };
13972    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13973    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13974        (Some(offer), Some(selected)) => negotiated_auth_proof(
13975            auth_token,
13976            AuthDirection::Server,
13977            hello.role,
13978            &hello.client_nonce,
13979            &server_nonce,
13980            offer,
13981            selected,
13982        ),
13983        (None, None) => auth_proof(
13984            auth_token,
13985            AuthDirection::Server,
13986            hello.role,
13987            &hello.client_nonce,
13988            &server_nonce,
13989        ),
13990        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13991    }
13992    .map_err(NodeServerError::Authentication)?;
13993    write_json_frame_limited(
13994        &mut pipe,
13995        &ServerFrame::Challenge(ServerChallenge {
13996            build_stamp: BUILD_STAMP.to_owned(),
13997            server_nonce,
13998            server_proof,
13999            compatibility: compatibility.clone(),
14000        }),
14001        MAX_NODE_HELLO_FRAME_BYTES,
14002    )
14003    .await?;
14004    let authentication = timeout(
14005        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14006        read_json_frame_limited_body_timeout(
14007            &mut pipe,
14008            MAX_NODE_HELLO_FRAME_BYTES,
14009            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14010        ),
14011    )
14012    .await
14013    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14014    let ClientFrame::Authenticate(authentication) = authentication else {
14015        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14016    };
14017    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14018        (Some(offer), Some(selected)) => negotiated_auth_proof(
14019            auth_token,
14020            AuthDirection::Client,
14021            hello.role,
14022            &hello.client_nonce,
14023            &server_nonce,
14024            offer,
14025            selected,
14026        ),
14027        (None, None) => auth_proof(
14028            auth_token,
14029            AuthDirection::Client,
14030            hello.role,
14031            &hello.client_nonce,
14032            &server_nonce,
14033        ),
14034        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14035    }
14036    .map_err(NodeServerError::Authentication)?;
14037    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14038        return Err(NodeServerError::Handshake("access denied".to_owned()));
14039    }
14040    let selected_capabilities = compatibility
14041        .as_ref()
14042        .map(|selected| selected.capabilities.clone())
14043        .unwrap_or_default();
14044    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14045        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14046    });
14047    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14048        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14049    });
14050    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14051        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14052    });
14053    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14054        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14055    });
14056    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14057        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14058    });
14059    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14060        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14061    }) && selected_capabilities.iter().any(|capability| {
14062        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14063    });
14064    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14065        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14066    });
14067    let include_session_bundles = selected_capabilities.iter().any(|capability| {
14068        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14069    });
14070    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14071        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14072    });
14073    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14074        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14075    });
14076    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14077        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14078    });
14079    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14080        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14081    });
14082    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14083        .try_acquire_owned()
14084        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14085    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14086    let _release_guard = ControllerReleaseGuard {
14087        shared: Arc::clone(&shared),
14088        connection_id,
14089    };
14090    drop(preauth_permit);
14091    let _authenticated_permit = authenticated_permit;
14092    let mut event_rx = shared.event_tx.subscribe();
14093    let mut terminal_event_rx = terminal_event_subscription(
14094        &shared,
14095        include_terminal_frame_events,
14096    );
14097    let mut agent_stream_event_rx = agent_stream_event_subscription(
14098        &shared,
14099        include_agent_stream_events,
14100    );
14101    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14102        &shared,
14103        include_harness_mcp_proxy,
14104    );
14105    write_json_frame(
14106        &mut pipe,
14107        &ServerFrame::Hello(NodeHello {
14108            build_stamp: BUILD_STAMP.to_owned(),
14109            incarnation_id: shared.incarnation_id,
14110            connection_id,
14111            role: hello.role,
14112            event_sequence: shared.current_sequence(),
14113            controller: shared.controller_state(),
14114            snapshot: snapshot_for_wire(
14115                &shared,
14116                include_provider_runtime_status,
14117                include_open_provider_ids,
14118                include_spawn_profiles,
14119                include_managed_worktrees,
14120                include_child_environment_profiles,
14121                include_session_bundles,
14122                include_history_context_packs,
14123                include_agent_progress,
14124                include_session_task_correlation,
14125            ),
14126            compatibility,
14127        }),
14128    )
14129    .await?;
14130    let (mut reader, mut writer) = tokio::io::split(pipe);
14131    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14132    let reader_task = tokio::spawn(async move {
14133        loop {
14134            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14135                &mut reader,
14136                MAX_NODE_CLIENT_FRAME_BYTES,
14137                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14138            )
14139            .await;
14140            let terminal = frame.is_err();
14141            if frame_tx.send(frame).await.is_err() || terminal {
14142                break;
14143            }
14144        }
14145    });
14146    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14147    let mut pending_events = Vec::new();
14148    let mut resync_required = false;
14149    let mut discard_events_through = 0_u64;
14150    loop {
14151        // One tick of the serve loop, whether or not it had anything to do.
14152        // Against `connection_events_sent` this separates "busy" from
14153        // "spinning": a loop turning over thousands of times a second while
14154        // sending nothing is not serving, it is burning a core.
14155        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14156        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14157            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14158        for _ in 0..durable_drain_budget {
14159            match event_rx.try_recv() {
14160                Ok(event) => queue_connection_event(
14161                    &mut pending_events,
14162                    event,
14163                    discard_events_through,
14164                ),
14165                Err(broadcast::error::TryRecvError::Empty) => break,
14166                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14167                    resync_required = true;
14168                    break;
14169                }
14170                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14171            }
14172        }
14173        if let Some(receiver) = terminal_event_rx.as_mut() {
14174            match receiver.try_recv() {
14175                Ok(events) => queue_connection_event_batch(
14176                    &mut pending_events,
14177                    &events,
14178                    discard_events_through,
14179                ),
14180                Err(broadcast::error::TryRecvError::Empty) => {}
14181                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14182                    resync_required = true;
14183                }
14184                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14185            }
14186        }
14187        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14188            // Loops up to the same remaining-room budget the durable
14189            // `event_rx` drain above uses, rather than one `try_recv` per
14190            // tick: each successful receive here is itself only ONE
14191            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14192            // sends `vec![envelope]`, never more), so a single-shot drain
14193            // caps this connection's agent-stream throughput at one chunk
14194            // per outer-loop tick regardless of how many are already queued
14195            // -- exactly the kind of self-inflicted stall this crate's own
14196            // durable-event budget exists to avoid.
14197            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14198                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14199            for _ in 0..agent_stream_drain_budget {
14200                match receiver.try_recv() {
14201                    Ok(events) => queue_connection_event_batch(
14202                        &mut pending_events,
14203                        &events,
14204                        discard_events_through,
14205                    ),
14206                    Err(broadcast::error::TryRecvError::Empty) => break,
14207                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14208                        tracing::warn!(
14209                            connection_id,
14210                            dropped,
14211                            "agent stream event subscription lagged; forcing a resync",
14212                        );
14213                        resync_required = true;
14214                        break;
14215                    }
14216                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14217                }
14218            }
14219        }
14220        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14221            // Mirrors the agent-stream drain immediately above: each
14222            // successful receive is one single-envelope batch
14223            // (`publish_transient` always sends `vec![envelope]`), so this
14224            // loops up to the same remaining-room budget rather than
14225            // stopping after one `try_recv`.
14226            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14227                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14228            for _ in 0..harness_mcp_proxy_drain_budget {
14229                match receiver.try_recv() {
14230                    Ok(events) => queue_connection_event_batch(
14231                        &mut pending_events,
14232                        &events,
14233                        discard_events_through,
14234                    ),
14235                    Err(broadcast::error::TryRecvError::Empty) => break,
14236                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14237                        tracing::warn!(
14238                            connection_id,
14239                            dropped,
14240                            "harness MCP proxy event subscription lagged; forcing a resync",
14241                        );
14242                        resync_required = true;
14243                        break;
14244                    }
14245                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14246                }
14247            }
14248        }
14249        if resync_required {
14250            let event = resync_required_event(&shared);
14251            apply_connection_resync_watermark(
14252                &mut pending_events,
14253                &mut discard_events_through,
14254                event.sequence,
14255            );
14256            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14257            resync_required = false;
14258            continue;
14259        }
14260        if !pending_events.is_empty() {
14261            pending_events.sort_unstable_by_key(|event| event.sequence);
14262            let burst_len = connection_event_burst_len(pending_events.len());
14263            shared
14264                .connection_events_sent
14265                .fetch_add(burst_len as u64, Ordering::Relaxed);
14266            for event in pending_events.drain(..burst_len) {
14267                let event = if include_managed_worktrees {
14268                    Some(event)
14269                } else {
14270                    project_event_without_managed_worktrees(event)
14271                };
14272                let event = event.and_then(|event| if include_open_provider_ids {
14273                    Some(event)
14274                } else {
14275                    project_event_legacy_provider_ids(&shared, event)
14276                });
14277                let event = event.map(|event| if include_child_environment_profiles {
14278                    event
14279                } else {
14280                    project_event_without_child_environment_profile(event)
14281                });
14282                let event = event.map(|event| if include_session_bundles {
14283                    event
14284                } else {
14285                    project_event_without_session_bundle(event)
14286                });
14287                let event = event.map(|event| {
14288                    project_event_history_for_wire(event, include_history_context_packs)
14289                });
14290                let event = event.map(|event| if include_history_context_packs {
14291                    event
14292                } else {
14293                    project_event_without_context_pack(event)
14294                });
14295                let event = event.map(|event| if include_session_task_correlation {
14296                    event
14297                } else {
14298                    project_event_without_session_task_binding(event)
14299                });
14300                let exact_harness_controller = hello.role == ClientRole::Operator
14301                    && shared.controller_state().is_some_and(|controller| {
14302                        controller.connection_id == connection_id
14303                    });
14304                let event = event.and_then(|event| {
14305                    if include_harness_mcp_proxy && exact_harness_controller {
14306                        Some(event)
14307                    } else {
14308                        project_event_without_harness_mcp_proxy(event)
14309                    }
14310                });
14311                if let Some(event) = event {
14312                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14313                }
14314            }
14315        }
14316        tokio::select! {
14317            biased;
14318            frame = frame_rx.recv() => {
14319                let frame = match frame {
14320                    Some(frame) => frame,
14321                    None => break,
14322                };
14323                let frame = match frame {
14324                    Ok(frame) => frame,
14325                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14326                    Err(error) => return Err(error.into()),
14327                };
14328                let ClientFrame::Request(request) = frame else {
14329                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14330                };
14331                #[cfg(feature = "fixture")]
14332                let probe_spawn_managed_worktree_v2 = matches!(
14333                    &request.request,
14334                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14335                );
14336                let requires_open_provider_ids =
14337                    request_requires_open_provider_ids(&shared, &request.request);
14338                let requires_child_environment_profile =
14339                    request_requires_child_environment_profile(&shared, &request.request);
14340                let requires_session_bundle =
14341                    request_requires_session_bundle(&shared, &request.request);
14342                let requires_history_context_pack =
14343                    request_requires_history_context_pack(&shared, &request.request);
14344                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14345                    ResponseEnvelope {
14346                        request_id: request.request_id,
14347                        result: Err(failure(
14348                            NodeFailureCode::UnsupportedCapability,
14349                            "open provider IDs were not negotiated",
14350                        )),
14351                    }
14352                } else if requires_child_environment_profile
14353                    && !include_child_environment_profiles
14354                {
14355                    ResponseEnvelope {
14356                        request_id: request.request_id,
14357                        result: Err(failure(
14358                            NodeFailureCode::UnsupportedCapability,
14359                            "child environment profiles were not negotiated",
14360                        )),
14361                    }
14362                } else if requires_session_bundle && !include_session_bundles {
14363                    ResponseEnvelope {
14364                        request_id: request.request_id,
14365                        result: Err(failure(
14366                            NodeFailureCode::UnsupportedCapability,
14367                            "session bundles were not negotiated",
14368                        )),
14369                    }
14370                } else if requires_history_context_pack && !include_history_context_packs {
14371                    ResponseEnvelope {
14372                        request_id: request.request_id,
14373                        result: Err(failure(
14374                            NodeFailureCode::UnsupportedCapability,
14375                            "history context packs were not negotiated",
14376                        )),
14377                    }
14378                } else if request_uses_unnegotiated_capability(
14379                    &request.request,
14380                    &selected_capabilities,
14381                )
14382                {
14383                    ResponseEnvelope {
14384                        request_id: request.request_id,
14385                        result: Err(failure(
14386                            NodeFailureCode::UnsupportedCapability,
14387                            "request capability was not negotiated",
14388                        )),
14389                    }
14390                } else {
14391                    process_request(&shared, connection_id, hello.role, request).await
14392                };
14393                #[cfg(feature = "fixture")]
14394                if probe_spawn_managed_worktree_v2 {
14395                    shared
14396                        .fixture_spawn_managed_worktree_v2_failure_probe
14397                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14398                }
14399                if !include_provider_runtime_status {
14400                    clear_response_provider_runtime_status(&mut reply);
14401                }
14402                if !include_managed_worktrees {
14403                    project_response_without_managed_worktrees(&mut reply);
14404                }
14405                if !include_open_provider_ids {
14406                    project_response_legacy_provider_ids(&shared, &mut reply);
14407                }
14408                if !include_child_environment_profiles {
14409                    project_response_without_child_environment_profile(&mut reply);
14410                }
14411                if !include_session_bundles {
14412                    project_response_without_session_bundle(&mut reply);
14413                }
14414                project_response_history_for_wire(
14415                    &mut reply,
14416                    include_history_context_packs,
14417                );
14418                if !include_history_context_packs {
14419                    project_response_without_context_pack(&mut reply);
14420                }
14421                if !include_agent_progress {
14422                    project_response_without_agent_progress(&mut reply);
14423                }
14424                if !include_session_task_correlation {
14425                    project_response_without_session_task_binding(&mut reply);
14426                }
14427                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14428            }
14429            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14430            event = event_rx.recv() => {
14431                match event {
14432                    Ok(event) => queue_connection_event(
14433                        &mut pending_events,
14434                        event,
14435                        discard_events_through,
14436                    ),
14437                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14438                    Err(broadcast::error::RecvError::Closed) => break,
14439                }
14440            }
14441            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14442                match events {
14443                    Ok(events) => queue_connection_event_batch(
14444                        &mut pending_events,
14445                        &events,
14446                        discard_events_through,
14447                    ),
14448                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14449                    Err(broadcast::error::RecvError::Closed) => break,
14450                }
14451            }
14452            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14453                match events {
14454                    Ok(events) => queue_connection_event_batch(
14455                        &mut pending_events,
14456                        &events,
14457                        discard_events_through,
14458                    ),
14459                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14460                    Err(broadcast::error::RecvError::Closed) => break,
14461                }
14462            }
14463            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14464                match events {
14465                    Ok(events) => queue_connection_event_batch(
14466                        &mut pending_events,
14467                        &events,
14468                        discard_events_through,
14469                    ),
14470                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14471                    Err(broadcast::error::RecvError::Closed) => break,
14472                }
14473            }
14474            _ = shared.shutdown_notify.notified() => break,
14475        }
14476    }
14477    Ok(())
14478}
14479
14480async fn receive_terminal_event_batch(
14481    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14482) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14483    match receiver {
14484        Some(receiver) => receiver.recv().await,
14485        None => std::future::pending().await,
14486    }
14487}
14488
14489fn terminal_event_subscription(
14490    shared: &NodeShared,
14491    enabled: bool,
14492) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14493    enabled.then(|| shared.terminal_event_tx.subscribe())
14494}
14495
14496/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14497/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14498/// this needs a dedicated channel rather than a flag on the terminal one.
14499fn agent_stream_event_subscription(
14500    shared: &NodeShared,
14501    enabled: bool,
14502) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14503    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14504}
14505
14506/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14507/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14508/// own doc for why this needs a dedicated channel rather than a ride on
14509/// the durable `event_tx`.
14510fn harness_mcp_proxy_event_subscription(
14511    shared: &NodeShared,
14512    enabled: bool,
14513) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14514    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14515}
14516
14517fn queue_connection_event(
14518    pending: &mut Vec<NodeEventEnvelope>,
14519    event: NodeEventEnvelope,
14520    discard_through: u64,
14521) {
14522    if event.sequence <= discard_through
14523        && !event.event.requires_harness_mcp_proxy_capability()
14524    {
14525        return;
14526    }
14527    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14528        if let Some(index) = pending.iter().position(|current| {
14529            matches!(
14530                &current.event,
14531                NodeEvent::TerminalFrame {
14532                    address: current_address,
14533                    ..
14534                } if current_address == address
14535            )
14536        }) {
14537            pending.remove(index);
14538        }
14539    }
14540    pending.push(event);
14541}
14542
14543fn project_event_without_harness_mcp_proxy(
14544    envelope: NodeEventEnvelope,
14545) -> Option<NodeEventEnvelope> {
14546    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14547}
14548
14549fn queue_connection_event_batch(
14550    pending: &mut Vec<NodeEventEnvelope>,
14551    events: &[NodeEventEnvelope],
14552    discard_through: u64,
14553) {
14554    for event in events {
14555        queue_connection_event(pending, event.clone(), discard_through);
14556    }
14557}
14558
14559fn apply_connection_resync_watermark(
14560    pending: &mut Vec<NodeEventEnvelope>,
14561    discard_through: &mut u64,
14562    marker_sequence: u64,
14563) {
14564    *discard_through = (*discard_through).max(marker_sequence);
14565    pending.retain(|event| event.sequence > *discard_through);
14566}
14567
14568fn connection_event_burst_len(pending_len: usize) -> usize {
14569    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14570}
14571
14572fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14573    let history = shared
14574        .history
14575        .lock()
14576        .unwrap_or_else(|poisoned| poisoned.into_inner());
14577    let sequence = history.last_sequence;
14578    let oldest_available_sequence = history.replay_floor_sequence;
14579    NodeEventEnvelope {
14580        sequence,
14581        event: NodeEvent::ResyncRequired {
14582            oldest_available_sequence,
14583        },
14584    }
14585}
14586
14587fn node_compatibility_support(
14588    shared: &NodeShared,
14589) -> Result<NodeCompatibilitySupport, NodeServerError> {
14590    let mut support = node_compatibility_support_for_manifest(
14591        &shared.provider_contracts,
14592        &shared.provider_adapter_contracts,
14593    )?;
14594    if shared
14595        .delivery_store
14596        .lock()
14597        .unwrap_or_else(|poisoned| poisoned.into_inner())
14598        .is_some()
14599    {
14600        support.capabilities.push(
14601            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14602                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14603        );
14604    }
14605    if shared.harness_mcp_registry.is_some() {
14606        support.capabilities.push(
14607            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14608                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14609        );
14610    }
14611    Ok(support)
14612}
14613
14614fn node_compatibility_support_for_manifest(
14615    provider_contracts: &[ProviderContractSupport],
14616    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14617) -> Result<NodeCompatibilitySupport, NodeServerError> {
14618    Ok(NodeCompatibilitySupport {
14619        build_stamp: BUILD_STAMP.to_owned(),
14620        capabilities: baseline_capabilities()?,
14621        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14622        path_semantics: platform::path_semantics(),
14623        local_transport: platform::local_transport(),
14624        state_schema: StateSchemaSupport {
14625            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14626                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14627        },
14628        provider_contracts: provider_contracts.to_vec(),
14629        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14630    })
14631}
14632
14633fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14634    let capabilities = [
14635        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14636        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14637        NODE_REPOSITORY_PATH_CAPABILITY,
14638        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14639        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14640        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14641        NODE_GIT_READ_CAPABILITY,
14642        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14643        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14644        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14645        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14646        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14647        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14648        NODE_ACP_CONTROL_CAPABILITY,
14649        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14650        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14651        NODE_WORKTREE_SELECTION_CAPABILITY,
14652        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14653        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14654        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14655        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14656        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14657        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14658        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14659        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14660        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14661        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14662        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14663        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14664    ]
14665        .into_iter()
14666        .map(|capability| {
14667            CapabilityId::new(capability)
14668                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14669        })
14670        .collect::<Result<Vec<_>, _>>()?;
14671    #[cfg(windows)]
14672    let capabilities = {
14673        let mut capabilities = capabilities;
14674        capabilities.push(
14675            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14676                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14677        );
14678        capabilities
14679    };
14680    Ok(capabilities)
14681}
14682
14683fn request_uses_unnegotiated_capability(
14684    request: &NodeRequest,
14685    selected_capabilities: &[CapabilityId],
14686) -> bool {
14687    let selected = |required: &str| {
14688        selected_capabilities
14689            .iter()
14690            .any(|capability| capability.as_str() == required)
14691    };
14692    request
14693        .required_capability()
14694        .is_some_and(|required| !selected(required))
14695        || (request.requires_worktree_selection_capability()
14696            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14697        || (request.requires_spawn_spec_defaults_overrides_capability()
14698            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14699        || (request.requires_spawn_profile_revision_capability()
14700            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14701        || (request.requires_child_environment_profile_capability()
14702            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14703        || (request.requires_session_bundle_materialization_capability()
14704            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14705        || (request.requires_history_context_pack_capability()
14706            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14707}
14708
14709fn snapshot_for_wire(
14710    shared: &NodeShared,
14711    include_provider_runtime_status: bool,
14712    include_open_provider_ids: bool,
14713    include_spawn_profiles: bool,
14714    include_managed_worktrees: bool,
14715    include_child_environment_profiles: bool,
14716    include_session_bundles: bool,
14717    include_history_context_packs: bool,
14718    include_agent_progress: bool,
14719    include_session_task_correlation: bool,
14720) -> NodeSnapshot {
14721    let mut snapshot = shared.snapshot();
14722    if !include_provider_runtime_status {
14723        snapshot.provider_runtime_statuses.clear();
14724    }
14725    if !include_open_provider_ids {
14726        project_snapshot_legacy_provider_ids(&mut snapshot);
14727    }
14728    if !include_managed_worktrees {
14729        snapshot.managed_worktrees.clear();
14730        for workspace in &mut snapshot.workspaces {
14731            workspace.managed_worktree_profiles = None;
14732        }
14733    }
14734    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14735        if !include_spawn_profiles {
14736            inventory.spawn_profiles = None;
14737        }
14738        if !include_session_bundles {
14739            inventory.bundles = None;
14740        }
14741        if inventory.spawn_profiles.is_none()
14742            && inventory.bundles.is_none()
14743            && inventory.network_allowlists.is_none()
14744        {
14745            snapshot.launch_inventory = None;
14746        }
14747    }
14748    if !include_child_environment_profiles {
14749        clear_snapshot_child_environment_profiles(&mut snapshot);
14750    }
14751    if !include_session_bundles {
14752        clear_snapshot_session_bundles(&mut snapshot);
14753    }
14754    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14755    if !include_history_context_packs {
14756        clear_snapshot_context_packs(&mut snapshot);
14757    }
14758    if !include_agent_progress {
14759        snapshot.agent_progress.clear();
14760    }
14761    if !include_session_task_correlation {
14762        clear_snapshot_session_task_bindings(&mut snapshot);
14763    }
14764    snapshot
14765}
14766
14767fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14768    for record in &mut snapshot.session_records {
14769        record.task_binding = None;
14770    }
14771}
14772
14773fn project_event_without_session_task_binding(
14774    mut envelope: NodeEventEnvelope,
14775) -> NodeEventEnvelope {
14776    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14777        record.task_binding = None;
14778    }
14779    envelope
14780}
14781
14782fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14783    let Ok(response) = reply.result.as_mut() else { return };
14784    match response {
14785        NodeResponse::Snapshot { snapshot, .. } => {
14786            clear_snapshot_session_task_bindings(snapshot);
14787        }
14788        NodeResponse::Resync { snapshot, events, .. } => {
14789            clear_snapshot_session_task_bindings(snapshot);
14790            for event in events {
14791                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14792                    record.task_binding = None;
14793                }
14794            }
14795        }
14796        NodeResponse::SessionRecordUpdated { record }
14797        | NodeResponse::ProviderSessionIndexed { record }
14798        | NodeResponse::NativeSessionIndexed { record, .. }
14799        | NodeResponse::SessionRecordResumed { record, .. } => {
14800            record.task_binding = None;
14801        }
14802        _ => {}
14803    }
14804}
14805
14806fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14807    let Ok(response) = reply.result.as_mut() else { return };
14808    match response {
14809        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14810            snapshot.agent_progress.clear();
14811        }
14812        _ => {}
14813    }
14814}
14815
14816fn project_event_without_managed_worktrees(
14817    mut envelope: NodeEventEnvelope,
14818) -> Option<NodeEventEnvelope> {
14819    if matches!(
14820        envelope.event,
14821        NodeEvent::ManagedWorktreeUpserted { .. }
14822            | NodeEvent::ManagedWorktreeRemoved { .. }
14823    ) {
14824        return None;
14825    }
14826    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14827        workspace.managed_worktree_profiles = None;
14828    }
14829    Some(envelope)
14830}
14831
14832fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14833    if matches!(
14834        reply.result,
14835        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14836            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14837    ) {
14838        reply.result = Err(failure(
14839            NodeFailureCode::UnsupportedCapability,
14840            "managed worktree capability was not negotiated",
14841        ));
14842        return;
14843    }
14844    let Ok(response) = reply.result.as_mut() else { return };
14845    match response {
14846        NodeResponse::Snapshot { snapshot, .. } => {
14847            snapshot.managed_worktrees.clear();
14848            for workspace in &mut snapshot.workspaces {
14849                workspace.managed_worktree_profiles = None;
14850            }
14851        }
14852        NodeResponse::Resync { snapshot, events, .. } => {
14853            snapshot.managed_worktrees.clear();
14854            for workspace in &mut snapshot.workspaces {
14855                workspace.managed_worktree_profiles = None;
14856            }
14857            *events = std::mem::take(events)
14858                .into_iter()
14859                .filter_map(project_event_without_managed_worktrees)
14860                .collect();
14861        }
14862        NodeResponse::WorkspaceRegistered { workspace }
14863        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14864        | NodeResponse::WorktreeCreated { workspace, .. } => {
14865            workspace.managed_worktree_profiles = None;
14866        }
14867        NodeResponse::WorkspaceInspected { inspection } => {
14868            inspection.git.managed_worktree = None;
14869        }
14870        _ => {}
14871    }
14872}
14873
14874fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14875    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14876        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14877    });
14878    if includes_open_provider_ids {
14879        return;
14880    }
14881    compatibility
14882        .provider_contracts
14883        .retain(|contract| provider_id_is_legacy(&contract.provider));
14884    compatibility
14885        .provider_adapter_contracts
14886        .retain(|contract| provider_id_is_legacy(&contract.provider));
14887}
14888
14889fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14890    if let Some(spec) = match request {
14891        NodeRequest::SpawnSpec { spec }
14892        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14893        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14894        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14895        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14896        _ => None,
14897    } {
14898        return shared
14899            .resolve_spawn_spec(spec)
14900            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14901    }
14902    request_requires_open_provider_ids_with(
14903        request,
14904        |session| shared.validate_address(session).ok(),
14905        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14906    )
14907}
14908
14909fn request_requires_child_environment_profile(
14910    shared: &NodeShared,
14911    request: &NodeRequest,
14912) -> bool {
14913    if let Some(spec) = match request {
14914        NodeRequest::SpawnSpec { spec }
14915        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14916        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14917        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14918        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14919        _ => None,
14920    } {
14921        return shared
14922            .resolve_spawn_spec(spec)
14923            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14924    }
14925    match request {
14926        NodeRequest::Resume { session, .. }
14927        | NodeRequest::Prompt { session, .. }
14928        | NodeRequest::Paste { session, .. }
14929        | NodeRequest::Input { session, .. }
14930        | NodeRequest::TerminalBytes { session, .. }
14931        | NodeRequest::TerminalControl { session, .. }
14932        | NodeRequest::Resize { session, .. }
14933        | NodeRequest::Interrupt { session }
14934        | NodeRequest::Stop { session, .. }
14935        | NodeRequest::Remove { session }
14936        | NodeRequest::ResolveInteraction { session, .. }
14937        | NodeRequest::SetSessionMode { session, .. }
14938        | NodeRequest::SetSessionConfigOption { session, .. }
14939        | NodeRequest::SetSessionModel { session, .. }
14940        | NodeRequest::DiscoverHistory { session, .. }
14941        | NodeRequest::LoadHistory { session, .. }
14942        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14943        | NodeRequest::ExportContextPack { session } => shared
14944            .session_bindings
14945            .lock()
14946            .unwrap_or_else(|poisoned| poisoned.into_inner())
14947            .get(&session.session.instance_id)
14948            .filter(|binding| {
14949                binding.workspace_id == session.workspace_id
14950                    && binding.generation == session.session.generation
14951            })
14952            .map_or(true, |binding| binding.environment_profile.is_some()),
14953        NodeRequest::RenameSessionRecord { record_id, .. }
14954        | NodeRequest::SetSessionTask { record_id, .. }
14955        | NodeRequest::ResumeSessionRecord { record_id, .. }
14956        | NodeRequest::ForgetSessionRecord { record_id } => shared
14957            .record(record_id)
14958            .map_or(true, |record| record.environment_profile.is_some()),
14959        NodeRequest::ArmHarnessMcpReservation { .. }
14960        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14961        | NodeRequest::ActivateHarnessMcpReservation { .. }
14962        | NodeRequest::AbortHarnessMcpReservation { .. }
14963        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14964        | NodeRequest::RejectHarnessMcpCall { .. }
14965        | NodeRequest::Snapshot
14966        | NodeRequest::Resync { .. }
14967        | NodeRequest::BeginDeliveryStage { .. }
14968        | NodeRequest::PutDeliveryBlobChunk { .. }
14969        | NodeRequest::CommitDeliveryStage { .. }
14970        | NodeRequest::AbortDeliveryStage { .. }
14971        | NodeRequest::BrowseHostDirectories { .. }
14972        | NodeRequest::InspectWorkspace { .. }
14973        | NodeRequest::ReadWorkspaceFile { .. }
14974        | NodeRequest::WriteWorkspaceFile { .. }
14975        | NodeRequest::CreateWorkspaceFile { .. }
14976        | NodeRequest::CreateWorkspaceDirectory { .. }
14977        | NodeRequest::ReadGitHistory { .. }
14978        | NodeRequest::ReadGitDiff { .. }
14979        | NodeRequest::AcquireController { .. }
14980        | NodeRequest::ReleaseController
14981        | NodeRequest::Spawn { .. }
14982        | NodeRequest::SpawnWithOptions { .. }
14983        | NodeRequest::SpawnSpec { .. }
14984        | NodeRequest::RegisterWorkspace { .. }
14985        | NodeRequest::CreateStandaloneWorkspace { .. }
14986        | NodeRequest::UnregisterWorkspace { .. }
14987        | NodeRequest::CreateWorktree { .. }
14988        | NodeRequest::RemoveWorktree { .. }
14989        | NodeRequest::SpawnManagedWorktree { .. }
14990        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14991        | NodeRequest::CleanupManagedWorktree { .. }
14992        | NodeRequest::IndexProviderSession { .. }
14993        | NodeRequest::IndexNativeSession { .. }
14994        | NodeRequest::CatalogNativeSessions { .. }
14995        | NodeRequest::PageNativeSessions { .. }
14996        | NodeRequest::PreviewNativeSession { .. }
14997        | NodeRequest::PreviewSessionRecord { .. }
14998        | NodeRequest::ForgetContextPack { .. }
14999        | NodeRequest::ResolveDurableContextPack { .. }
15000        | NodeRequest::ReadContextPack { .. }
15001        | NodeRequest::Shutdown => false,
15002    }
15003}
15004
15005fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15006    if let Some(spec) = match request {
15007        NodeRequest::SpawnSpec { spec }
15008        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15009        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15010        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15011        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15012        _ => None,
15013    } {
15014        return shared
15015            .resolve_spawn_spec(spec)
15016            .is_ok_and(|resolved| resolved.bundle_id.is_some());
15017    }
15018    match request {
15019        NodeRequest::Resume { session, .. }
15020        | NodeRequest::Prompt { session, .. }
15021        | NodeRequest::Paste { session, .. }
15022        | NodeRequest::Input { session, .. }
15023        | NodeRequest::TerminalBytes { session, .. }
15024        | NodeRequest::TerminalControl { session, .. }
15025        | NodeRequest::Resize { session, .. }
15026        | NodeRequest::Interrupt { session }
15027        | NodeRequest::Stop { session, .. }
15028        | NodeRequest::Remove { session }
15029        | NodeRequest::ResolveInteraction { session, .. }
15030        | NodeRequest::SetSessionMode { session, .. }
15031        | NodeRequest::SetSessionConfigOption { session, .. }
15032        | NodeRequest::SetSessionModel { session, .. }
15033        | NodeRequest::DiscoverHistory { session, .. }
15034        | NodeRequest::LoadHistory { session, .. }
15035        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15036        | NodeRequest::ExportContextPack { session } => shared
15037            .session_bindings
15038            .lock()
15039            .unwrap_or_else(|poisoned| poisoned.into_inner())
15040            .get(&session.session.instance_id)
15041            .filter(|binding| {
15042                binding.workspace_id == session.workspace_id
15043                    && binding.generation == session.session.generation
15044            })
15045            .map_or(true, |binding| binding.bundle.is_some()),
15046        NodeRequest::RenameSessionRecord { record_id, .. }
15047        | NodeRequest::SetSessionTask { record_id, .. }
15048        | NodeRequest::ResumeSessionRecord { record_id, .. }
15049        | NodeRequest::ForgetSessionRecord { record_id } => shared
15050            .record(record_id)
15051            .map_or(true, |record| record.bundle.is_some()),
15052        NodeRequest::ArmHarnessMcpReservation { .. }
15053        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15054        | NodeRequest::ActivateHarnessMcpReservation { .. }
15055        | NodeRequest::AbortHarnessMcpReservation { .. }
15056        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15057        | NodeRequest::RejectHarnessMcpCall { .. }
15058        | NodeRequest::Snapshot
15059        | NodeRequest::Resync { .. }
15060        | NodeRequest::BeginDeliveryStage { .. }
15061        | NodeRequest::PutDeliveryBlobChunk { .. }
15062        | NodeRequest::CommitDeliveryStage { .. }
15063        | NodeRequest::AbortDeliveryStage { .. }
15064        | NodeRequest::BrowseHostDirectories { .. }
15065        | NodeRequest::InspectWorkspace { .. }
15066        | NodeRequest::ReadWorkspaceFile { .. }
15067        | NodeRequest::WriteWorkspaceFile { .. }
15068        | NodeRequest::CreateWorkspaceFile { .. }
15069        | NodeRequest::CreateWorkspaceDirectory { .. }
15070        | NodeRequest::ReadGitHistory { .. }
15071        | NodeRequest::ReadGitDiff { .. }
15072        | NodeRequest::AcquireController { .. }
15073        | NodeRequest::ReleaseController
15074        | NodeRequest::Spawn { .. }
15075        | NodeRequest::SpawnWithOptions { .. }
15076        | NodeRequest::SpawnSpec { .. }
15077        | NodeRequest::RegisterWorkspace { .. }
15078        | NodeRequest::CreateStandaloneWorkspace { .. }
15079        | NodeRequest::UnregisterWorkspace { .. }
15080        | NodeRequest::CreateWorktree { .. }
15081        | NodeRequest::RemoveWorktree { .. }
15082        | NodeRequest::SpawnManagedWorktree { .. }
15083        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15084        | NodeRequest::CleanupManagedWorktree { .. }
15085        | NodeRequest::IndexProviderSession { .. }
15086        | NodeRequest::IndexNativeSession { .. }
15087        | NodeRequest::CatalogNativeSessions { .. }
15088        | NodeRequest::PageNativeSessions { .. }
15089        | NodeRequest::PreviewNativeSession { .. }
15090        | NodeRequest::PreviewSessionRecord { .. }
15091        | NodeRequest::ForgetContextPack { .. }
15092        | NodeRequest::ResolveDurableContextPack { .. }
15093        | NodeRequest::ReadContextPack { .. }
15094        | NodeRequest::Shutdown => false,
15095    }
15096}
15097
15098fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15099    if request.requires_history_context_pack_capability() {
15100        return true;
15101    }
15102    if let Some(spec) = match request {
15103        NodeRequest::SpawnSpec { spec }
15104        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15105        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15106        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15107        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15108        _ => None,
15109    } {
15110        return shared
15111            .resolve_spawn_spec(spec)
15112            .map_or(true, |resolved| resolved.context_id.is_some());
15113    }
15114    match request {
15115        NodeRequest::Resume { session, .. }
15116        | NodeRequest::Prompt { session, .. }
15117        | NodeRequest::Paste { session, .. }
15118        | NodeRequest::Input { session, .. }
15119        | NodeRequest::TerminalBytes { session, .. }
15120        | NodeRequest::TerminalControl { session, .. }
15121        | NodeRequest::Resize { session, .. }
15122        | NodeRequest::Interrupt { session }
15123        | NodeRequest::Stop { session, .. }
15124        | NodeRequest::Remove { session }
15125        | NodeRequest::ResolveInteraction { session, .. }
15126        | NodeRequest::SetSessionMode { session, .. }
15127        | NodeRequest::SetSessionConfigOption { session, .. }
15128        | NodeRequest::SetSessionModel { session, .. } => shared
15129            .session_bindings
15130            .lock()
15131            .unwrap_or_else(|poisoned| poisoned.into_inner())
15132            .get(&session.session.instance_id)
15133            .filter(|binding| {
15134                binding.workspace_id == session.workspace_id
15135                    && binding.generation == session.session.generation
15136            })
15137            .map_or(true, |binding| binding.context.is_some()),
15138        NodeRequest::RenameSessionRecord { record_id, .. }
15139        | NodeRequest::SetSessionTask { record_id, .. }
15140        | NodeRequest::ResumeSessionRecord { record_id, .. }
15141        | NodeRequest::ForgetSessionRecord { record_id } => shared
15142            .record(record_id)
15143            .map_or(true, |record| record.context.is_some()),
15144        NodeRequest::ArmHarnessMcpReservation { .. }
15145        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15146        | NodeRequest::ActivateHarnessMcpReservation { .. }
15147        | NodeRequest::AbortHarnessMcpReservation { .. }
15148        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15149        | NodeRequest::RejectHarnessMcpCall { .. }
15150        | NodeRequest::Snapshot
15151        | NodeRequest::Resync { .. }
15152        | NodeRequest::BeginDeliveryStage { .. }
15153        | NodeRequest::PutDeliveryBlobChunk { .. }
15154        | NodeRequest::CommitDeliveryStage { .. }
15155        | NodeRequest::AbortDeliveryStage { .. }
15156        | NodeRequest::BrowseHostDirectories { .. }
15157        | NodeRequest::InspectWorkspace { .. }
15158        | NodeRequest::ReadWorkspaceFile { .. }
15159        | NodeRequest::WriteWorkspaceFile { .. }
15160        | NodeRequest::CreateWorkspaceFile { .. }
15161        | NodeRequest::CreateWorkspaceDirectory { .. }
15162        | NodeRequest::ReadGitHistory { .. }
15163        | NodeRequest::ReadGitDiff { .. }
15164        | NodeRequest::AcquireController { .. }
15165        | NodeRequest::ReleaseController
15166        | NodeRequest::Spawn { .. }
15167        | NodeRequest::SpawnWithOptions { .. }
15168        | NodeRequest::SpawnSpec { .. }
15169        | NodeRequest::RegisterWorkspace { .. }
15170        | NodeRequest::CreateStandaloneWorkspace { .. }
15171        | NodeRequest::UnregisterWorkspace { .. }
15172        | NodeRequest::CreateWorktree { .. }
15173        | NodeRequest::RemoveWorktree { .. }
15174        | NodeRequest::SpawnManagedWorktree { .. }
15175        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15176        | NodeRequest::CleanupManagedWorktree { .. }
15177        | NodeRequest::IndexProviderSession { .. }
15178        | NodeRequest::IndexNativeSession { .. }
15179        | NodeRequest::CatalogNativeSessions { .. }
15180        | NodeRequest::PageNativeSessions { .. }
15181        | NodeRequest::PreviewNativeSession { .. }
15182        | NodeRequest::PreviewSessionRecord { .. }
15183        | NodeRequest::DiscoverHistory { .. }
15184        | NodeRequest::LoadHistory { .. }
15185        | NodeRequest::ExportContextPackForSessionRecord { .. }
15186        | NodeRequest::ExportContextPack { .. }
15187        | NodeRequest::ForgetContextPack { .. }
15188        | NodeRequest::ResolveDurableContextPack { .. }
15189        | NodeRequest::ReadContextPack { .. }
15190        | NodeRequest::Shutdown => false,
15191    }
15192}
15193
15194fn request_requires_open_provider_ids_with(
15195    request: &NodeRequest,
15196    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15197    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15198) -> bool {
15199    match request {
15200        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15201        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15202        NodeRequest::CatalogNativeSessions { route, .. }
15203        | NodeRequest::PageNativeSessions { route, .. } => {
15204            !provider_id_is_legacy(&route.provider)
15205        }
15206        NodeRequest::PreviewNativeSession { selection, .. }
15207        | NodeRequest::IndexNativeSession { selection, .. } => {
15208            !provider_id_is_legacy(&selection.route.provider)
15209        }
15210        NodeRequest::SpawnSpec { .. }
15211        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15212        NodeRequest::SpawnManagedWorktree { .. }
15213        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15214        NodeRequest::Resume { session, .. }
15215        | NodeRequest::Prompt { session, .. }
15216        | NodeRequest::Paste { session, .. }
15217        | NodeRequest::Input { session, .. }
15218        | NodeRequest::TerminalBytes { session, .. }
15219        | NodeRequest::TerminalControl { session, .. }
15220        | NodeRequest::Resize { session, .. }
15221        | NodeRequest::Interrupt { session }
15222        | NodeRequest::Stop { session, .. }
15223        | NodeRequest::Remove { session }
15224        | NodeRequest::ResolveInteraction { session, .. }
15225        | NodeRequest::SetSessionMode { session, .. }
15226        | NodeRequest::SetSessionConfigOption { session, .. }
15227        | NodeRequest::SetSessionModel { session, .. }
15228        | NodeRequest::DiscoverHistory { session, .. }
15229        | NodeRequest::LoadHistory { session, .. }
15230        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15231        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15232            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15233        NodeRequest::RenameSessionRecord { record_id, .. }
15234        | NodeRequest::SetSessionTask { record_id, .. }
15235        | NodeRequest::ResumeSessionRecord { record_id, .. }
15236        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15237            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15238        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15239            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15240        NodeRequest::ArmHarnessMcpReservation { .. }
15241        | NodeRequest::ActivateHarnessMcpReservation { .. }
15242        | NodeRequest::AbortHarnessMcpReservation { .. }
15243        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15244        | NodeRequest::RejectHarnessMcpCall { .. }
15245        | NodeRequest::Snapshot
15246        | NodeRequest::Resync { .. }
15247        | NodeRequest::BeginDeliveryStage { .. }
15248        | NodeRequest::PutDeliveryBlobChunk { .. }
15249        | NodeRequest::CommitDeliveryStage { .. }
15250        | NodeRequest::AbortDeliveryStage { .. }
15251        | NodeRequest::BrowseHostDirectories { .. }
15252        | NodeRequest::InspectWorkspace { .. }
15253        | NodeRequest::ReadWorkspaceFile { .. }
15254        | NodeRequest::WriteWorkspaceFile { .. }
15255        | NodeRequest::CreateWorkspaceFile { .. }
15256        | NodeRequest::CreateWorkspaceDirectory { .. }
15257        | NodeRequest::ReadGitHistory { .. }
15258        | NodeRequest::ReadGitDiff { .. }
15259        | NodeRequest::AcquireController { .. }
15260        | NodeRequest::ReleaseController
15261        | NodeRequest::RegisterWorkspace { .. }
15262        | NodeRequest::CreateStandaloneWorkspace { .. }
15263        | NodeRequest::UnregisterWorkspace { .. }
15264        | NodeRequest::CreateWorktree { .. }
15265        | NodeRequest::RemoveWorktree { .. }
15266        | NodeRequest::CleanupManagedWorktree { .. }
15267        | NodeRequest::Shutdown => false,
15268        NodeRequest::ForgetContextPack { .. } => true,
15269        // No session/record to cheaply resolve a provider from here either;
15270        // the resolved receipt can carry an open-id provider, so require the
15271        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15272        // is the same digest-keyed lookup one step further into the same
15273        // catalog, so it inherits the same conservative answer.
15274        NodeRequest::ResolveDurableContextPack { .. }
15275        | NodeRequest::ReadContextPack { .. } => true,
15276    }
15277}
15278
15279fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15280    snapshot.enabled_providers.retain(provider_id_is_legacy);
15281    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15282        snapshot
15283            .provider_runtime_statuses
15284            .as_slice()
15285            .iter()
15286            .filter(|status| provider_id_is_legacy(status.provider()))
15287            .cloned(),
15288    )
15289    .expect("a filtered provider runtime projection remains valid");
15290    snapshot
15291        .session_records
15292        .retain(|record| provider_id_is_legacy(&record.provider));
15293    for workspace in &mut snapshot.workspaces {
15294        workspace
15295            .sessions
15296            .retain(|session| provider_id_is_legacy(&session.agent_id));
15297    }
15298}
15299
15300fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15301    for record in &mut snapshot.session_records {
15302        record.environment_profile = None;
15303    }
15304    if let Some(profiles) = snapshot
15305        .launch_inventory
15306        .as_mut()
15307        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15308    {
15309        for profile in profiles {
15310            profile.environment_profile = None;
15311        }
15312    }
15313}
15314
15315fn project_event_without_child_environment_profile(
15316    mut envelope: NodeEventEnvelope,
15317) -> NodeEventEnvelope {
15318    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15319        record.environment_profile = None;
15320    }
15321    envelope
15322}
15323
15324fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15325    let contains_environment_profile = match reply.result.as_ref() {
15326        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15327            receipt.environment_profile.is_some()
15328        }
15329        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15330            receipt.spawn.environment_profile.is_some()
15331        }
15332        _ => false,
15333    };
15334    if contains_environment_profile {
15335        reply.result = Err(failure(
15336            NodeFailureCode::UnsupportedCapability,
15337            "child environment profiles were not negotiated",
15338        ));
15339        return;
15340    }
15341    let Ok(response) = reply.result.as_mut() else {
15342        return;
15343    };
15344    match response {
15345        NodeResponse::Snapshot { snapshot, .. } => {
15346            clear_snapshot_child_environment_profiles(snapshot);
15347        }
15348        NodeResponse::Resync { snapshot, events, .. } => {
15349            clear_snapshot_child_environment_profiles(snapshot);
15350            for event in events {
15351                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15352                    record.environment_profile = None;
15353                }
15354            }
15355        }
15356        NodeResponse::SessionRecordUpdated { record }
15357        | NodeResponse::ProviderSessionIndexed { record }
15358        | NodeResponse::NativeSessionIndexed { record, .. }
15359        | NodeResponse::SessionRecordResumed { record, .. } => {
15360            record.environment_profile = None;
15361        }
15362        NodeResponse::Armed { .. }
15363        | NodeResponse::Spawned { .. }
15364        | NodeResponse::Activated { .. }
15365        | NodeResponse::Aborted { .. }
15366        | NodeResponse::ReplyChunkAccepted { .. }
15367        | NodeResponse::CallRejected { .. }
15368        | NodeResponse::DeliveryStageBegun { .. }
15369        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15370        | NodeResponse::DeliveryCommitted { .. }
15371        | NodeResponse::DeliveryStageAborted { .. }
15372        | NodeResponse::WorkspaceInspected { .. }
15373        | NodeResponse::HostDirectoriesBrowsed { .. }
15374        | NodeResponse::WorkspaceFileRead { .. }
15375        | NodeResponse::WorkspaceFileWritten { .. }
15376        | NodeResponse::WorkspaceFileCreated { .. }
15377        | NodeResponse::WorkspaceDirectoryCreated { .. }
15378        | NodeResponse::GitHistoryRead { .. }
15379        | NodeResponse::GitDiffRead { .. }
15380        | NodeResponse::Controller { .. }
15381        | NodeResponse::SpawnAccepted { .. }
15382        | NodeResponse::SpawnSpecAccepted { .. }
15383        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15384        | NodeResponse::ManagedWorktreeCleanup { .. }
15385        | NodeResponse::NativeSessionsCataloged { .. }
15386        | NodeResponse::NativeSessionsPaged { .. }
15387        | NodeResponse::NativeSessionPreviewed { .. }
15388        | NodeResponse::SessionRecordPreviewed { .. }
15389        | NodeResponse::HistoryDiscovered { .. }
15390        | NodeResponse::HistoryLoaded { .. }
15391        | NodeResponse::ContextPackForSessionRecordExported { .. }
15392        | NodeResponse::ContextPackExported { .. }
15393        | NodeResponse::ContextPackForgotten { .. }
15394        | NodeResponse::DurableContextPackResolved { .. }
15395        | NodeResponse::ContextPackBytesRead { .. }
15396        | NodeResponse::SessionRecordForgotten { .. }
15397        | NodeResponse::WorkspaceRegistered { .. }
15398        | NodeResponse::StandaloneWorkspaceCreated { .. }
15399        | NodeResponse::WorkspaceUnregistered { .. }
15400        | NodeResponse::WorktreeCreated { .. }
15401        | NodeResponse::WorktreeRemoved { .. }
15402        | NodeResponse::Accepted
15403        | NodeResponse::ShuttingDown => {}
15404    }
15405}
15406
15407fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15408    for record in &mut snapshot.session_records {
15409        record.bundle = None;
15410    }
15411}
15412
15413fn project_event_without_session_bundle(
15414    mut envelope: NodeEventEnvelope,
15415) -> NodeEventEnvelope {
15416    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15417        record.bundle = None;
15418    }
15419    envelope
15420}
15421
15422fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15423    let contains_bundle = match reply.result.as_ref() {
15424        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15425        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15426            receipt.spawn.bundle.is_some()
15427        }
15428        _ => false,
15429    };
15430    if contains_bundle {
15431        reply.result = Err(failure(
15432            NodeFailureCode::UnsupportedCapability,
15433            "session bundles were not negotiated",
15434        ));
15435        return;
15436    }
15437    let Ok(response) = reply.result.as_mut() else {
15438        return;
15439    };
15440    match response {
15441        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15442        NodeResponse::Resync { snapshot, events, .. } => {
15443            clear_snapshot_session_bundles(snapshot);
15444            for event in events {
15445                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15446                    record.bundle = None;
15447                }
15448            }
15449        }
15450        NodeResponse::SessionRecordUpdated { record }
15451        | NodeResponse::ProviderSessionIndexed { record }
15452        | NodeResponse::NativeSessionIndexed { record, .. }
15453        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15454        NodeResponse::Armed { .. }
15455        | NodeResponse::Spawned { .. }
15456        | NodeResponse::Activated { .. }
15457        | NodeResponse::Aborted { .. }
15458        | NodeResponse::ReplyChunkAccepted { .. }
15459        | NodeResponse::CallRejected { .. }
15460        | NodeResponse::DeliveryStageBegun { .. }
15461        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15462        | NodeResponse::DeliveryCommitted { .. }
15463        | NodeResponse::DeliveryStageAborted { .. }
15464        | NodeResponse::WorkspaceInspected { .. }
15465        | NodeResponse::HostDirectoriesBrowsed { .. }
15466        | NodeResponse::WorkspaceFileRead { .. }
15467        | NodeResponse::WorkspaceFileWritten { .. }
15468        | NodeResponse::WorkspaceFileCreated { .. }
15469        | NodeResponse::WorkspaceDirectoryCreated { .. }
15470        | NodeResponse::GitHistoryRead { .. }
15471        | NodeResponse::GitDiffRead { .. }
15472        | NodeResponse::Controller { .. }
15473        | NodeResponse::SpawnAccepted { .. }
15474        | NodeResponse::SpawnSpecAccepted { .. }
15475        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15476        | NodeResponse::ManagedWorktreeCleanup { .. }
15477        | NodeResponse::NativeSessionsCataloged { .. }
15478        | NodeResponse::NativeSessionsPaged { .. }
15479        | NodeResponse::NativeSessionPreviewed { .. }
15480        | NodeResponse::SessionRecordPreviewed { .. }
15481        | NodeResponse::HistoryDiscovered { .. }
15482        | NodeResponse::HistoryLoaded { .. }
15483        | NodeResponse::ContextPackForSessionRecordExported { .. }
15484        | NodeResponse::ContextPackExported { .. }
15485        | NodeResponse::ContextPackForgotten { .. }
15486        | NodeResponse::DurableContextPackResolved { .. }
15487        | NodeResponse::ContextPackBytesRead { .. }
15488        | NodeResponse::SessionRecordForgotten { .. }
15489        | NodeResponse::WorkspaceRegistered { .. }
15490        | NodeResponse::StandaloneWorkspaceCreated { .. }
15491        | NodeResponse::WorkspaceUnregistered { .. }
15492        | NodeResponse::WorktreeCreated { .. }
15493        | NodeResponse::WorktreeRemoved { .. }
15494        | NodeResponse::Accepted
15495        | NodeResponse::ShuttingDown => {}
15496    }
15497}
15498
15499fn project_snapshot_history_for_wire(
15500    snapshot: &mut NodeSnapshot,
15501    include_history_metadata: bool,
15502) {
15503    for workspace in &mut snapshot.workspaces {
15504        for session in &mut workspace.sessions {
15505            if include_history_metadata {
15506                session.history.loaded = None;
15507            } else {
15508                session.history = Default::default();
15509            }
15510        }
15511    }
15512}
15513
15514fn project_event_history_for_wire(
15515    mut envelope: NodeEventEnvelope,
15516    include_history_metadata: bool,
15517) -> NodeEventEnvelope {
15518    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15519        for session in &mut workspace.sessions {
15520            if include_history_metadata {
15521                session.history.loaded = None;
15522            } else {
15523                session.history = Default::default();
15524            }
15525        }
15526    }
15527    envelope
15528}
15529
15530fn project_response_history_for_wire(
15531    reply: &mut ResponseEnvelope,
15532    include_history_metadata: bool,
15533) {
15534    let Ok(response) = reply.result.as_mut() else {
15535        return;
15536    };
15537    match response {
15538        NodeResponse::Snapshot { snapshot, .. } => {
15539            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15540        }
15541        NodeResponse::Resync {
15542            snapshot, events, ..
15543        } => {
15544            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15545            for event in events {
15546                *event = project_event_history_for_wire(
15547                    event.clone(),
15548                    include_history_metadata,
15549                );
15550            }
15551        }
15552        NodeResponse::WorkspaceRegistered { workspace }
15553        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15554        | NodeResponse::WorktreeCreated { workspace, .. } => {
15555            for session in &mut workspace.sessions {
15556                if include_history_metadata {
15557                    session.history.loaded = None;
15558                } else {
15559                    session.history = Default::default();
15560                }
15561            }
15562        }
15563        _ => {}
15564    }
15565}
15566
15567fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15568    for record in &mut snapshot.session_records {
15569        record.context_id = None;
15570        record.context = None;
15571        record.exported_context = None;
15572    }
15573}
15574
15575fn project_event_without_context_pack(
15576    mut envelope: NodeEventEnvelope,
15577) -> NodeEventEnvelope {
15578    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15579        record.context_id = None;
15580        record.context = None;
15581        record.exported_context = None;
15582    }
15583    envelope
15584}
15585
15586fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15587    let contains_context = match reply.result.as_ref() {
15588        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15589        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15590            receipt.spawn.context.is_some()
15591        }
15592        Ok(NodeResponse::HistoryDiscovered { .. })
15593        | Ok(NodeResponse::HistoryLoaded { .. })
15594        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15595        | Ok(NodeResponse::ContextPackExported { .. })
15596        | Ok(NodeResponse::ContextPackForgotten { .. })
15597        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15598        _ => false,
15599    };
15600    if contains_context {
15601        reply.result = Err(failure(
15602            NodeFailureCode::UnsupportedCapability,
15603            "history context packs were not negotiated",
15604        ));
15605        return;
15606    }
15607    let Ok(response) = reply.result.as_mut() else {
15608        return;
15609    };
15610    match response {
15611        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15612        NodeResponse::Resync {
15613            snapshot, events, ..
15614        } => {
15615            clear_snapshot_context_packs(snapshot);
15616            for event in events {
15617                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15618                    record.context_id = None;
15619                    record.context = None;
15620                    record.exported_context = None;
15621                }
15622            }
15623        }
15624        NodeResponse::SessionRecordUpdated { record }
15625        | NodeResponse::ProviderSessionIndexed { record }
15626        | NodeResponse::NativeSessionIndexed { record, .. }
15627        | NodeResponse::SessionRecordResumed { record, .. } => {
15628            record.context_id = None;
15629            record.context = None;
15630            record.exported_context = None;
15631        }
15632        _ => {}
15633    }
15634}
15635
15636fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15637    let contains_open_record = match reply.result.as_ref() {
15638        Ok(NodeResponse::SessionRecordUpdated { record })
15639        | Ok(NodeResponse::ProviderSessionIndexed { record })
15640        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15641            !provider_id_is_legacy(&record.provider)
15642        }
15643        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15644            !provider_id_is_legacy(&selection.route.provider)
15645                || !provider_id_is_legacy(&record.provider)
15646        }
15647        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15648            !provider_id_is_legacy(&receipt.provider)
15649        }
15650        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15651            !provider_id_is_legacy(&receipt.spawn.provider)
15652        }
15653        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15654        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15655            !provider_id_is_legacy(&route.provider)
15656        }
15657        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15658            !provider_id_is_legacy(&selection.route.provider)
15659        }
15660        _ => false,
15661    };
15662    if contains_open_record {
15663        reply.result = Err(failure(
15664            NodeFailureCode::UnsupportedCapability,
15665            "open provider IDs were not negotiated",
15666        ));
15667        return;
15668    }
15669    let Ok(response) = reply.result.as_mut() else {
15670        return;
15671    };
15672    match response {
15673        NodeResponse::Snapshot { snapshot, .. } => {
15674            project_snapshot_legacy_provider_ids(snapshot);
15675        }
15676        NodeResponse::Resync { snapshot, events, .. } => {
15677            project_snapshot_legacy_provider_ids(snapshot);
15678            *events = std::mem::take(events)
15679                .into_iter()
15680                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15681                .collect();
15682        }
15683        NodeResponse::WorkspaceRegistered { workspace }
15684        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15685        | NodeResponse::WorktreeCreated { workspace, .. } => {
15686            workspace
15687                .sessions
15688                .retain(|session| provider_id_is_legacy(&session.agent_id));
15689        }
15690        NodeResponse::Armed { .. }
15691        | NodeResponse::Spawned { .. }
15692        | NodeResponse::Activated { .. }
15693        | NodeResponse::Aborted { .. }
15694        | NodeResponse::ReplyChunkAccepted { .. }
15695        | NodeResponse::CallRejected { .. }
15696        | NodeResponse::DeliveryStageBegun { .. }
15697        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15698        | NodeResponse::DeliveryCommitted { .. }
15699        | NodeResponse::DeliveryStageAborted { .. }
15700        | NodeResponse::WorkspaceInspected { .. }
15701        | NodeResponse::HostDirectoriesBrowsed { .. }
15702        | NodeResponse::WorkspaceFileRead { .. }
15703        | NodeResponse::WorkspaceFileWritten { .. }
15704        | NodeResponse::WorkspaceFileCreated { .. }
15705        | NodeResponse::WorkspaceDirectoryCreated { .. }
15706        | NodeResponse::GitHistoryRead { .. }
15707        | NodeResponse::GitDiffRead { .. }
15708        | NodeResponse::Controller { .. }
15709        | NodeResponse::SpawnAccepted { .. }
15710        | NodeResponse::SpawnSpecAccepted { .. }
15711        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15712        | NodeResponse::ManagedWorktreeCleanup { .. }
15713        | NodeResponse::NativeSessionsCataloged { .. }
15714        | NodeResponse::NativeSessionsPaged { .. }
15715        | NodeResponse::NativeSessionPreviewed { .. }
15716        | NodeResponse::SessionRecordPreviewed { .. }
15717        | NodeResponse::HistoryDiscovered { .. }
15718        | NodeResponse::HistoryLoaded { .. }
15719        | NodeResponse::ContextPackForSessionRecordExported { .. }
15720        | NodeResponse::ContextPackExported { .. }
15721        | NodeResponse::ContextPackForgotten { .. }
15722        | NodeResponse::DurableContextPackResolved { .. }
15723        | NodeResponse::ContextPackBytesRead { .. }
15724        | NodeResponse::SessionRecordUpdated { .. }
15725        | NodeResponse::ProviderSessionIndexed { .. }
15726        | NodeResponse::NativeSessionIndexed { .. }
15727        | NodeResponse::SessionRecordResumed { .. }
15728        | NodeResponse::SessionRecordForgotten { .. }
15729        | NodeResponse::WorkspaceUnregistered { .. }
15730        | NodeResponse::WorktreeRemoved { .. }
15731        | NodeResponse::Accepted
15732        | NodeResponse::ShuttingDown => {}
15733    }
15734}
15735
15736fn project_event_legacy_provider_ids(
15737    shared: &NodeShared,
15738    mut envelope: NodeEventEnvelope,
15739) -> Option<NodeEventEnvelope> {
15740    let include = match &mut envelope.event {
15741        NodeEvent::HarnessMcpReadCall { .. } => true,
15742        NodeEvent::Control { address, .. }
15743        | NodeEvent::AgentStream { address, .. }
15744        | NodeEvent::TerminalFrame { address, .. } => shared
15745            .validate_address(address)
15746            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15747        NodeEvent::WorkspaceAdded { workspace } => {
15748            workspace
15749                .sessions
15750                .retain(|session| provider_id_is_legacy(&session.agent_id));
15751            true
15752        }
15753        NodeEvent::SessionRecordUpserted { record } => {
15754            provider_id_is_legacy(&record.provider)
15755        }
15756        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15757        NodeEvent::SessionRecordRemoved { .. } => shared
15758            .history
15759            .lock()
15760            .unwrap_or_else(|poisoned| poisoned.into_inner())
15761            .removed_record_providers
15762            .get(&envelope.sequence)
15763            .is_some_and(provider_id_is_legacy),
15764        NodeEvent::ControllerChanged { .. }
15765        | NodeEvent::ManagedWorktreeUpserted { .. }
15766        | NodeEvent::ManagedWorktreeRemoved { .. }
15767        | NodeEvent::WorkspaceRemoved { .. }
15768        | NodeEvent::ResyncRequired { .. } => true,
15769    };
15770    include.then_some(envelope)
15771}
15772
15773fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15774    let Ok(response) = reply.result.as_mut() else {
15775        return;
15776    };
15777    match response {
15778        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15779            snapshot.provider_runtime_statuses.clear();
15780        }
15781        NodeResponse::Armed { .. }
15782        | NodeResponse::Spawned { .. }
15783        | NodeResponse::Activated { .. }
15784        | NodeResponse::Aborted { .. }
15785        | NodeResponse::ReplyChunkAccepted { .. }
15786        | NodeResponse::CallRejected { .. }
15787        | NodeResponse::DeliveryStageBegun { .. }
15788        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15789        | NodeResponse::DeliveryCommitted { .. }
15790        | NodeResponse::DeliveryStageAborted { .. }
15791        | NodeResponse::WorkspaceInspected { .. }
15792        | NodeResponse::HostDirectoriesBrowsed { .. }
15793        | NodeResponse::WorkspaceFileRead { .. }
15794        | NodeResponse::WorkspaceFileWritten { .. }
15795        | NodeResponse::WorkspaceFileCreated { .. }
15796        | NodeResponse::WorkspaceDirectoryCreated { .. }
15797        | NodeResponse::GitHistoryRead { .. }
15798        | NodeResponse::GitDiffRead { .. }
15799        | NodeResponse::Controller { .. }
15800        | NodeResponse::SpawnAccepted { .. }
15801        | NodeResponse::SpawnSpecAccepted { .. }
15802        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15803        | NodeResponse::ManagedWorktreeCleanup { .. }
15804        | NodeResponse::NativeSessionsCataloged { .. }
15805        | NodeResponse::NativeSessionsPaged { .. }
15806        | NodeResponse::NativeSessionPreviewed { .. }
15807        | NodeResponse::SessionRecordPreviewed { .. }
15808        | NodeResponse::HistoryDiscovered { .. }
15809        | NodeResponse::HistoryLoaded { .. }
15810        | NodeResponse::ContextPackForSessionRecordExported { .. }
15811        | NodeResponse::ContextPackExported { .. }
15812        | NodeResponse::ContextPackForgotten { .. }
15813        | NodeResponse::DurableContextPackResolved { .. }
15814        | NodeResponse::ContextPackBytesRead { .. }
15815        | NodeResponse::SessionRecordUpdated { .. }
15816        | NodeResponse::ProviderSessionIndexed { .. }
15817        | NodeResponse::NativeSessionIndexed { .. }
15818        | NodeResponse::SessionRecordResumed { .. }
15819        | NodeResponse::SessionRecordForgotten { .. }
15820        | NodeResponse::WorkspaceRegistered { .. }
15821        | NodeResponse::StandaloneWorkspaceCreated { .. }
15822        | NodeResponse::WorkspaceUnregistered { .. }
15823        | NodeResponse::WorktreeCreated { .. }
15824        | NodeResponse::WorktreeRemoved { .. }
15825        | NodeResponse::Accepted
15826        | NodeResponse::ShuttingDown => {}
15827    }
15828}
15829
15830struct ControllerReleaseGuard {
15831    shared: Arc<NodeShared>,
15832    connection_id: u64,
15833}
15834
15835impl Drop for ControllerReleaseGuard {
15836    fn drop(&mut self) {
15837        self.shared.release_controller(self.connection_id);
15838    }
15839}
15840
15841struct AbortTaskOnDrop(AbortHandle);
15842
15843impl Drop for AbortTaskOnDrop {
15844    fn drop(&mut self) {
15845        self.0.abort();
15846    }
15847}
15848
15849/// Reads an unsigned env-config override, following the pattern of every
15850/// other bounded knob in this module: missing/unparsable/zero falls back to
15851/// `default`, and the resolved value is always clamped to `max` so a bad
15852/// override cannot reopen a bound this budget exists to enforce.
15853fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15854    std::env::var(name)
15855        .ok()
15856        .and_then(|value| value.parse::<u64>().ok())
15857        .filter(|value| *value > 0)
15858        .unwrap_or(default)
15859        .min(max)
15860}
15861
15862fn workspace_inspection_time_budget_ms() -> u64 {
15863    env_config_bounded(
15864        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15865        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15866        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15867    )
15868}
15869
15870fn workspace_inspection_entry_cap() -> usize {
15871    env_config_bounded(
15872        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15873        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15874        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15875    ) as usize
15876}
15877
15878/// Tracks the shared walk-side budget across the whole recursive walk: a
15879/// wall-clock deadline (shared with the git phase that runs after the walk)
15880/// and a cap on directory entries visited, whether or not they were pushed
15881/// to the response. Cheap to check per entry — one `Instant::now()` and one
15882/// integer compare.
15883struct WorkspaceWalkBudget {
15884    deadline: Instant,
15885    entry_cap: usize,
15886    entries_visited: u64,
15887    time_budget_exceeded: bool,
15888    entry_cap_exceeded: bool,
15889}
15890
15891impl WorkspaceWalkBudget {
15892    fn new(deadline: Instant, entry_cap: usize) -> Self {
15893        Self {
15894            deadline,
15895            entry_cap,
15896            entries_visited: 0,
15897            time_budget_exceeded: false,
15898            entry_cap_exceeded: false,
15899        }
15900    }
15901
15902    fn is_exhausted(&self) -> bool {
15903        self.time_budget_exceeded || self.entry_cap_exceeded
15904    }
15905
15906    /// Records one visited directory entry (pushed to the response or not)
15907    /// and reports whether the walk must stop now.
15908    fn record_visit(&mut self) -> bool {
15909        self.entries_visited += 1;
15910        if self.entries_visited as usize >= self.entry_cap {
15911            self.entry_cap_exceeded = true;
15912        }
15913        if Instant::now() >= self.deadline {
15914            self.time_budget_exceeded = true;
15915        }
15916        self.is_exhausted()
15917    }
15918}
15919
15920fn collect_workspace_entries(
15921    root: &Path,
15922    deadline: Instant,
15923    entry_cap: usize,
15924) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15925    let mut entries = Vec::new();
15926    let mut truncated = false;
15927    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15928    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15929    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15930    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15931    // require strict global byte order over relative paths.
15932    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15933    (entries, truncated, budget)
15934}
15935
15936/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15937/// `inspect_workspace` response — `None` once the walk and git phase both
15938/// completed inside their shared budget.
15939fn workspace_inspection_truncation(
15940    walk_budget: &WorkspaceWalkBudget,
15941    git_time_budget_exceeded: bool,
15942    elapsed: Duration,
15943) -> Option<WorkspaceInspectionTruncationV1> {
15944    if !walk_budget.time_budget_exceeded
15945        && !walk_budget.entry_cap_exceeded
15946        && !git_time_budget_exceeded
15947    {
15948        return None;
15949    }
15950    Some(WorkspaceInspectionTruncationV1 {
15951        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15952        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15953        git_time_budget_exceeded,
15954        entries_visited: walk_budget.entries_visited,
15955        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15956    })
15957}
15958
15959fn walk_workspace_directory(
15960    directory: &Path,
15961    relative_directory: Option<&RepositoryPath>,
15962    depth: usize,
15963    entries: &mut Vec<WorkspaceEntry>,
15964    truncated: &mut bool,
15965    budget: &mut WorkspaceWalkBudget,
15966) {
15967    if budget.is_exhausted() {
15968        *truncated = true;
15969        return;
15970    }
15971    let read_dir = match std::fs::read_dir(directory) {
15972        Ok(read_dir) => read_dir,
15973        Err(_) => {
15974            *truncated = true;
15975            return;
15976        }
15977    };
15978    let mut children = Vec::new();
15979    for child in read_dir {
15980        match child {
15981            Ok(child) => children.push(child),
15982            Err(_) => *truncated = true,
15983        }
15984    }
15985    children.sort_by_key(|child| child.file_name());
15986    for child in children {
15987        if budget.record_visit() {
15988            *truncated = true;
15989            return;
15990        }
15991        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15992            *truncated = true;
15993            return;
15994        }
15995        let file_type = match child.file_type() {
15996            Ok(file_type) => file_type,
15997            Err(_) => {
15998                *truncated = true;
15999                continue;
16000            }
16001        };
16002        if file_type.is_symlink() {
16003            continue;
16004        }
16005        let name = match child.file_name().into_string() {
16006            Ok(name) => name,
16007            Err(_) => {
16008                *truncated = true;
16009                continue;
16010            }
16011        };
16012        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16013            continue;
16014        }
16015        let relative_path = match relative_directory {
16016            None => windows_repository_path(name),
16017            Some(parent) => parent
16018                .as_utf8()
16019                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16020        };
16021        let Some(relative_path) = relative_path else {
16022            *truncated = true;
16023            continue;
16024        };
16025        let kind = if file_type.is_dir() {
16026            WorkspaceEntryKind::Directory
16027        } else if file_type.is_file() {
16028            WorkspaceEntryKind::File
16029        } else {
16030            continue;
16031        };
16032        entries.push(WorkspaceEntry {
16033            relative_path: relative_path.clone(),
16034            kind,
16035        });
16036        if file_type.is_dir() {
16037            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16038                walk_workspace_directory(
16039                    &child.path(),
16040                    Some(&relative_path),
16041                    depth + 1,
16042                    entries,
16043                    truncated,
16044                    budget,
16045                );
16046            } else {
16047                *truncated = true;
16048            }
16049        }
16050    }
16051}
16052
16053fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16054    if value.contains('\\') {
16055        return None;
16056    }
16057    RepositoryPath::utf8(value).ok()
16058}
16059
16060fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16061    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16062}
16063
16064/// Excludes a directory from the workspace tree walk. The three literal
16065/// names are the common convention-based cases; the `CACHEDIR.TAG` check
16066/// beneath them catches every build-output directory regardless of name
16067/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
16068/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16069/// directory without maintaining a name heuristic list). The name check
16070/// runs first and returns early so a directory rejected by name never pays
16071/// for the extra `CACHEDIR.TAG` stat.
16072fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16073    if name.eq_ignore_ascii_case(".git")
16074        || name.eq_ignore_ascii_case("target")
16075        || name.eq_ignore_ascii_case("node_modules")
16076    {
16077        return true;
16078    }
16079    path.join("CACHEDIR.TAG").is_file()
16080}
16081
16082fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16083    matches!(
16084        status,
16085        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16086    )
16087}
16088
16089#[derive(Clone, Debug, Eq, PartialEq)]
16090enum ContextPackRepositoryHead {
16091    NotRepository,
16092    Unborn,
16093    Commit(String),
16094}
16095
16096#[derive(Clone, Debug, Eq, PartialEq)]
16097struct ContextPackRepositoryObservation {
16098    head: ContextPackRepositoryHead,
16099    git: GitSnapshot,
16100    files: Vec<ContextPackRepositoryFileSource>,
16101}
16102
16103fn stable_context_pack_repository(
16104    first: ContextPackRepositoryObservation,
16105    second: ContextPackRepositoryObservation,
16106) -> Result<ContextPackRepository, NodeFailure> {
16107    if first != second {
16108        return Err(failure(
16109            NodeFailureCode::ContextPackMaterializationFailed,
16110            "context repository changed during bounded capture",
16111        ));
16112    }
16113    Ok(ContextPackRepository::from_sources(
16114        &second.git,
16115        second.files,
16116    ))
16117}
16118
16119/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16120/// (and only once) the shared walk+git `deadline` has already passed. Every
16121/// remaining phase in `inspect_git_workspace` checks this immediately
16122/// before issuing its own git call, so a budget spent by the walk (or by an
16123/// earlier git call) skips the rest of the chain instead of paying for it
16124/// anyway.
16125fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16126    if Instant::now() < deadline {
16127        return false;
16128    }
16129    append_git_diagnostic(
16130        snapshot,
16131        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16132    );
16133    true
16134}
16135
16136/// Deadline-aware sibling of `run_git_bounded`, used only by
16137/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16138/// whatever remains of the shared `deadline` (never more than
16139/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16140/// of the budget cannot still hand a full per-call timeout to every git
16141/// probe behind it. `inspect_git_workspace` already guards each call site
16142/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16143/// passed yet here.
16144async fn run_git_bounded_with_deadline(
16145    root: &str,
16146    arguments: &[&str],
16147    output_limit: usize,
16148    deadline: Instant,
16149) -> io::Result<GitCommandOutput> {
16150    let remaining = deadline.checked_duration_since(Instant::now());
16151    let timeout_ms = remaining
16152        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16153        .unwrap_or(0)
16154        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16155    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16156}
16157
16158/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16159/// `deadline` is the same absolute instant the preceding directory walk was
16160/// bounded by, so time already spent walking counts against what remains
16161/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16162/// shared budget (not an individual per-call timeout) is what cut the git
16163/// phase short.
16164async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16165    let mut snapshot = GitSnapshot {
16166        is_repository: false,
16167        branch: None,
16168        status: Vec::new(),
16169        recent_commits: Vec::new(),
16170        worktrees: Vec::new(),
16171        managed_worktree: None,
16172        truncated: false,
16173        diagnostic: None,
16174    };
16175    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16176        return (snapshot, true);
16177    }
16178    let repository = match run_git_bounded_with_deadline(
16179        root,
16180        &["rev-parse", "--is-inside-work-tree"],
16181        4 * 1_024,
16182        deadline,
16183    )
16184    .await
16185    {
16186        Ok(output) => output,
16187        Err(error) => {
16188            append_git_diagnostic(
16189                &mut snapshot,
16190                &format!("git inspection unavailable: {error}"),
16191            );
16192            return (snapshot, false);
16193        }
16194    };
16195    snapshot.truncated |= repository.truncated;
16196    if repository.timed_out {
16197        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16198        return (snapshot, false);
16199    }
16200    if !repository.success
16201        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16202    {
16203        let stderr = String::from_utf8_lossy(&repository.stderr);
16204        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16205            append_git_diagnostic(&mut snapshot, stderr.trim());
16206        }
16207        return (snapshot, false);
16208    }
16209    snapshot.is_repository = true;
16210
16211    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16212        return (snapshot, true);
16213    }
16214    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16215        Ok(output) => {
16216            snapshot.truncated |= output.truncated;
16217            if output.timed_out {
16218                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16219            } else if output.success {
16220                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16221                if !branch.is_empty() {
16222                    snapshot.branch = Some(truncate_git_field(
16223                        &branch,
16224                        MAX_REPOSITORY_PATH_BYTES,
16225                        &mut snapshot.truncated,
16226                    ));
16227                }
16228            }
16229        }
16230        Err(error) => append_git_diagnostic(
16231            &mut snapshot,
16232            &format!("git branch query failed: {error}"),
16233        ),
16234    }
16235    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16236        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16237            snapshot.truncated |= output.truncated;
16238            if output.success && !output.timed_out {
16239                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16240                if !head.is_empty() {
16241                    snapshot.branch = Some(format!("detached:{head}"));
16242                }
16243            }
16244        }
16245    }
16246
16247    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16248        return (snapshot, true);
16249    }
16250    match run_git_bounded_with_deadline(
16251        root,
16252        &[
16253            "status",
16254            "--porcelain=v1",
16255            "-z",
16256            "--renames",
16257            "--untracked-files=normal",
16258            "--",
16259            ".",
16260        ],
16261        GIT_OUTPUT_MAX_BYTES,
16262        deadline,
16263    )
16264    .await
16265    {
16266        Ok(output) => {
16267            snapshot.truncated |= output.truncated;
16268            if output.timed_out {
16269                append_git_diagnostic(&mut snapshot, "git status timed out");
16270            } else if output.success {
16271                parse_git_status(&output.stdout, &mut snapshot);
16272            } else {
16273                append_git_diagnostic(
16274                    &mut snapshot,
16275                    String::from_utf8_lossy(&output.stderr).trim(),
16276                );
16277            }
16278        }
16279        Err(error) => append_git_diagnostic(
16280            &mut snapshot,
16281            &format!("git status failed: {error}"),
16282        ),
16283    }
16284
16285    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16286        return (snapshot, true);
16287    }
16288    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16289    match run_git_bounded_with_deadline(
16290        root,
16291        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16292        16 * 1_024,
16293        deadline,
16294    )
16295    .await
16296    {
16297        Ok(output) => {
16298            snapshot.truncated |= output.truncated;
16299            if output.timed_out {
16300                append_git_diagnostic(&mut snapshot, "git log timed out");
16301            } else if output.success {
16302                parse_git_commits(&output.stdout, &mut snapshot);
16303            } else {
16304                let stderr = String::from_utf8_lossy(&output.stderr);
16305                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16306                    append_git_diagnostic(&mut snapshot, stderr.trim());
16307                }
16308            }
16309        }
16310        Err(error) => append_git_diagnostic(
16311            &mut snapshot,
16312            &format!("git log failed: {error}"),
16313        ),
16314    }
16315
16316    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16317        return (snapshot, true);
16318    }
16319    match list_git_worktrees_with_deadline(root, deadline).await {
16320        Ok(worktrees) => {
16321            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16322        }
16323        Err(error) => append_git_diagnostic(
16324            &mut snapshot,
16325            &format!("git worktree list failed: {}", error.message),
16326        ),
16327    }
16328    (snapshot, false)
16329}
16330
16331fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16332    let mut remaining = output;
16333    while !remaining.is_empty() {
16334        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16335            snapshot.truncated = true;
16336            break;
16337        }
16338        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16339            snapshot.truncated = true;
16340            break;
16341        };
16342        let record = &remaining[..record_end];
16343        remaining = &remaining[record_end + 1..];
16344        let has_previous_path = record
16345            .get(..2)
16346            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16347        let previous_path_bytes = if has_previous_path {
16348            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16349                snapshot.truncated = true;
16350                break;
16351            };
16352            let previous = &remaining[..previous_end];
16353            remaining = &remaining[previous_end + 1..];
16354            Some(previous)
16355        } else {
16356            None
16357        };
16358        if record.len() < 4
16359            || record[2] != b' '
16360            || !is_git_status_code(record[0])
16361            || !is_git_status_code(record[1])
16362        {
16363            snapshot.truncated = true;
16364            continue;
16365        }
16366        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16367            snapshot.truncated = true;
16368            continue;
16369        };
16370        let previous_path = match previous_path_bytes {
16371            Some(previous) => {
16372                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16373                    snapshot.truncated = true;
16374                    continue;
16375                };
16376                Some(previous)
16377            }
16378            None => None,
16379        };
16380        snapshot.status.push(GitStatusEntry {
16381            index_status: (record[0] as char).to_string(),
16382            worktree_status: (record[1] as char).to_string(),
16383            path,
16384            previous_path,
16385        });
16386    }
16387}
16388
16389fn is_git_status_code(byte: u8) -> bool {
16390    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16391}
16392
16393fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16394    for line in String::from_utf8_lossy(output).lines() {
16395        let Some((id, summary)) = line.split_once('\u{1f}') else {
16396            continue;
16397        };
16398        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16399            snapshot.truncated = true;
16400            break;
16401        }
16402        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16403        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16404        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16405    }
16406}
16407
16408fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16409    if value.len() <= max_bytes {
16410        return value.to_owned();
16411    }
16412    *truncated = true;
16413    let mut boundary = max_bytes;
16414    while boundary > 0 && !value.is_char_boundary(boundary) {
16415        boundary -= 1;
16416    }
16417    value[..boundary].to_owned()
16418}
16419
16420fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16421    if message.is_empty() {
16422        return;
16423    }
16424    let normalized = message
16425        .chars()
16426        .map(|character| if character.is_control() { ' ' } else { character })
16427        .collect::<String>();
16428    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16429    if !diagnostic.is_empty() {
16430        diagnostic.push_str("; ");
16431    }
16432    diagnostic.push_str(normalized.trim());
16433    snapshot.diagnostic = Some(truncate_git_field(
16434        &diagnostic,
16435        GIT_DIAGNOSTIC_MAX_BYTES,
16436        &mut snapshot.truncated,
16437    ));
16438}
16439
16440async fn run_git_bounded(
16441    root: &str,
16442    arguments: &[&str],
16443    output_limit: usize,
16444) -> io::Result<GitCommandOutput> {
16445    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16446}
16447
16448async fn read_git_history_bounded(
16449    root: &str,
16450    path: Option<&RepositoryPath>,
16451    before: Option<&GitObjectId>,
16452    limit: u16,
16453) -> Result<GitHistoryPage, NodeFailure> {
16454    let path = path
16455        .map(|path| {
16456            path.as_utf8().ok_or_else(|| {
16457                failure(
16458                    NodeFailureCode::InvalidRepositoryPath,
16459                    "git history path must use UTF-8",
16460                )
16461            })
16462        })
16463        .transpose()?
16464        .unwrap_or(".");
16465    let count = (usize::from(limit) + 1).to_string();
16466    let start = before
16467        .map(|revision| format!("{}^", revision.as_str()))
16468        .unwrap_or_else(|| "HEAD".to_owned());
16469    let arguments = [
16470        "--no-pager",
16471        "log",
16472        "--no-decorate",
16473        "--date=iso-strict",
16474        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16475        "-n",
16476        count.as_str(),
16477        start.as_str(),
16478        "--",
16479        path,
16480    ];
16481    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16482        .await
16483        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16484    if output.timed_out {
16485        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16486    }
16487    if !output.success {
16488        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16489    }
16490    let mut commits = parse_git_history(&output.stdout);
16491    let has_more = commits.len() > usize::from(limit) || output.truncated;
16492    commits.truncate(usize::from(limit));
16493    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16494    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16495}
16496
16497fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16498    String::from_utf8_lossy(output)
16499        .split('\u{1e}')
16500        .filter_map(|record| {
16501            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16502            if fields.len() != 9 {
16503                return None;
16504            }
16505            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16506            let parents = if fields[1].is_empty() {
16507                Vec::new()
16508            } else {
16509                fields[1]
16510                    .split(' ')
16511                    .map(|value| GitObjectId::new(value.to_owned()))
16512                    .collect::<Result<Vec<_>, _>>()
16513                    .ok()?
16514            };
16515            Some(GitCommitDetails {
16516                id,
16517                parents,
16518                subject: bounded_git_text(fields[2], 512),
16519                author_name: bounded_git_text(fields[3], 256),
16520                author_email: bounded_git_text(fields[4], 320),
16521                authored_at: bounded_git_text(fields[5], 64),
16522                committer_name: bounded_git_text(fields[6], 256),
16523                committer_email: bounded_git_text(fields[7], 320),
16524                committed_at: bounded_git_text(fields[8], 64),
16525                signature_status: GitSignatureStatus::NoSignature,
16526                signer: None,
16527            })
16528        })
16529        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16530        .collect()
16531}
16532
16533fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16534    let normalized = value
16535        .chars()
16536        .map(|character| if character.is_control() { ' ' } else { character })
16537        .collect::<String>();
16538    if normalized.len() <= max_bytes {
16539        return normalized;
16540    }
16541    let mut boundary = max_bytes;
16542    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16543        boundary -= 1;
16544    }
16545    normalized[..boundary].to_owned()
16546}
16547
16548async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16549    let path = request.path.as_ref().map(|path| {
16550        path.as_utf8()
16551            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16552    }).transpose()?;
16553    let mut arguments = match &request.mode {
16554        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16555        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16556        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16557    };
16558    if let GitDiffMode::Commit { revision } = &request.mode {
16559        arguments.push(revision.as_str());
16560    }
16561    arguments.push("--");
16562    if let Some(path) = path {
16563        arguments.push(path);
16564    }
16565    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16566        .await
16567        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16568    if output.timed_out {
16569        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16570    }
16571    if !output.success {
16572        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16573    }
16574    Ok(GitDiff {
16575        mode: request.mode,
16576        path: request.path,
16577        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16578        truncated: output.truncated,
16579    })
16580}
16581
16582async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16583    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16584    ResponseEnvelope { request_id: envelope.request_id, result }
16585}
16586
16587async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16588    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16589        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16590    }
16591    let read_only = matches!(
16592        &request,
16593        NodeRequest::Snapshot
16594            | NodeRequest::Resync { .. }
16595            | NodeRequest::BrowseHostDirectories { .. }
16596            | NodeRequest::InspectWorkspace { .. }
16597            | NodeRequest::ReadWorkspaceFile { .. }
16598            | NodeRequest::ReadGitHistory { .. }
16599            | NodeRequest::ReadGitDiff { .. }
16600            | NodeRequest::CatalogNativeSessions { .. }
16601            | NodeRequest::PageNativeSessions { .. }
16602            | NodeRequest::PreviewNativeSession { .. }
16603            | NodeRequest::PreviewSessionRecord { .. }
16604    );
16605    let _mutation_guard = if read_only {
16606        None
16607    } else {
16608        let guard = shared.mutation_gate.lock().await;
16609        if shared.shutdown.load(Ordering::Acquire) {
16610            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16611        }
16612        Some(guard)
16613    };
16614    match request {
16615        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16616            event_sequence: shared.current_sequence(),
16617            controller: shared.controller_state(),
16618            snapshot: shared.snapshot(),
16619        }),
16620        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16621        NodeRequest::ArmHarnessMcpReservation {
16622            reservation_id,
16623            activation_digest,
16624            spawn_spec,
16625            launch,
16626            expires_at_unix_ms,
16627        } => {
16628            shared.require_controller(connection_id, role)?;
16629            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16630                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16631            })?;
16632            let expires_at_unix_ms = registry
16633                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16634                .await
16635                .map_err(harness_mcp_failure)?;
16636            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16637        }
16638        NodeRequest::SpawnSpecWithHarnessMcp {
16639            reservation_id,
16640            activation_digest,
16641            spec,
16642            deadline_unix_ms,
16643        } => {
16644            shared.require_controller(connection_id, role)?;
16645            let receipt = shared
16646                .spawn_from_spec_with_harness_mcp(
16647                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16648                )
16649                .await?;
16650            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16651        }
16652        NodeRequest::ActivateHarnessMcpReservation {
16653            reservation_id,
16654            activation_digest,
16655            record_id,
16656            session,
16657        } => {
16658            shared.require_controller(connection_id, role)?;
16659            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16660                current.instance_id == session.session.instance_id
16661                    && current.generation == session.session.generation
16662            }).and_then(|current| current.process_id).ok_or_else(|| {
16663                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16664            })?;
16665            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16666                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16667            })?.activate(
16668                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16669            ).map_err(harness_mcp_failure)?;
16670            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16671        }
16672        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16673            shared.require_controller(connection_id, role)?;
16674            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16675                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16676            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16677            if let Some(instance_id) = instance_id {
16678                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16679            }
16680            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16681        }
16682        NodeRequest::PutHarnessMcpReplyChunk {
16683            reservation_id, activation_digest, record_id, session, call_id,
16684            offset, final_chunk, chunk_hex,
16685        } => {
16686            shared.require_controller(connection_id, role)?;
16687            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16688                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16689            })?.put_reply_chunk(
16690                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16691                offset, final_chunk, &chunk_hex,
16692            ).map_err(harness_mcp_failure)?;
16693            Ok(NodeResponse::ReplyChunkAccepted {
16694                reservation_id, activation_digest, record_id, session, call_id,
16695                next_offset, completed,
16696            })
16697        }
16698        NodeRequest::RejectHarnessMcpCall {
16699            reservation_id, activation_digest, record_id, session, call_id, reason,
16700        } => {
16701            shared.require_controller(connection_id, role)?;
16702            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16703                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16704            })?.reject_call(
16705                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16706            ).map_err(harness_mcp_failure)?;
16707            Ok(NodeResponse::CallRejected {
16708                reservation_id, activation_digest, record_id, session, call_id,
16709            })
16710        }
16711        NodeRequest::BeginDeliveryStage { manifest } => {
16712            shared.require_controller(connection_id, role)?;
16713            shared.begin_delivery_stage(manifest)
16714        }
16715        NodeRequest::PutDeliveryBlobChunk {
16716            stage_id,
16717            blob_digest,
16718            offset,
16719            chunk_hex,
16720        } => {
16721            shared.require_controller(connection_id, role)?;
16722            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16723        }
16724        NodeRequest::CommitDeliveryStage { stage_id } => {
16725            shared.require_controller(connection_id, role)?;
16726            let receipt = shared.commit_delivery_stage(stage_id)?;
16727            Ok(NodeResponse::DeliveryCommitted { receipt })
16728        }
16729        NodeRequest::AbortDeliveryStage { stage_id } => {
16730            shared.require_controller(connection_id, role)?;
16731            shared.abort_delivery_stage(&stage_id)?;
16732            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16733        }
16734        NodeRequest::BrowseHostDirectories { directory, after } => {
16735            let listing = shared.browse_host_directories(directory, after).await?;
16736            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16737        }
16738        NodeRequest::InspectWorkspace { workspace_id } => {
16739            let inspection = shared.inspect_workspace(workspace_id).await?;
16740            Ok(NodeResponse::WorkspaceInspected { inspection })
16741        }
16742        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16743            let file = shared.read_workspace_file(workspace_id, path).await?;
16744            Ok(NodeResponse::WorkspaceFileRead { file })
16745        }
16746        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16747            shared.require_controller(connection_id, role)?;
16748            let file = shared
16749                .write_workspace_file(workspace_id, path, expected_revision, text)
16750                .await?;
16751            Ok(NodeResponse::WorkspaceFileWritten { file })
16752        }
16753        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16754            shared.require_controller(connection_id, role)?;
16755            let file = shared.create_workspace_file(workspace_id, path).await?;
16756            Ok(NodeResponse::WorkspaceFileCreated { file })
16757        }
16758        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16759            shared.require_controller(connection_id, role)?;
16760            let entry = shared
16761                .create_workspace_directory(workspace_id.clone(), path)
16762                .await?;
16763            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16764        }
16765        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16766            if role != ClientRole::Operator {
16767                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16768            }
16769            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16770                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16771            }
16772            let page = shared
16773                .read_git_history(workspace_id.clone(), path, before, limit)
16774                .await?;
16775            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16776        }
16777        NodeRequest::ReadGitDiff { workspace_id, request } => {
16778            if role != ClientRole::Operator {
16779                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16780            }
16781            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16782            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16783        }
16784        NodeRequest::CatalogNativeSessions { route, limit } => {
16785            if role != ClientRole::Operator {
16786                return Err(failure(
16787                    NodeFailureCode::ObserverReadOnly,
16788                    "native session catalog requires an operator connection",
16789                ));
16790            }
16791            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16792                return Err(failure(
16793                    NodeFailureCode::InvalidRequest,
16794                    "native session catalog limit is invalid",
16795                ));
16796            }
16797            let (entries, summary) = shared
16798                .catalog_native_sessions(route.clone(), limit)
16799                .await?;
16800            Ok(NodeResponse::NativeSessionsCataloged {
16801                route,
16802                entries,
16803                summary: Some(summary),
16804            })
16805        }
16806        NodeRequest::PageNativeSessions {
16807            route,
16808            window,
16809            catalog_revision,
16810            recent_cutoff_unix_ms,
16811            after_selection_id,
16812            limit,
16813        } => {
16814            if role != ClientRole::Operator {
16815                return Err(failure(
16816                    NodeFailureCode::ObserverReadOnly,
16817                    "native session catalog paging requires an operator connection",
16818                ));
16819            }
16820            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16821                || after_selection_id
16822                    .as_deref()
16823                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16824            {
16825                return Err(failure(
16826                    NodeFailureCode::InvalidRequest,
16827                    "native session catalog page request is invalid",
16828                ));
16829            }
16830            let page = shared
16831                .page_native_sessions(
16832                    route.clone(),
16833                    window,
16834                    catalog_revision,
16835                    recent_cutoff_unix_ms,
16836                    after_selection_id,
16837                    limit,
16838                )
16839                .await?;
16840            Ok(NodeResponse::NativeSessionsPaged {
16841                route,
16842                page,
16843            })
16844        }
16845        NodeRequest::PreviewNativeSession {
16846            selection,
16847            message_limit,
16848        } => {
16849            if role != ClientRole::Operator {
16850                return Err(failure(
16851                    NodeFailureCode::ObserverReadOnly,
16852                    "native session preview requires an operator connection",
16853                ));
16854            }
16855            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16856                .contains(&message_limit)
16857                || selection.validate().is_err()
16858            {
16859                return Err(failure(
16860                    NodeFailureCode::InvalidRequest,
16861                    "native session preview request is invalid",
16862                ));
16863            }
16864            let preview = shared
16865                .preview_native_session(selection.clone(), message_limit)
16866                .await?;
16867            Ok(NodeResponse::NativeSessionPreviewed {
16868                selection,
16869                preview,
16870            })
16871        }
16872        NodeRequest::IndexNativeSession {
16873            selection,
16874            display_name,
16875        } => {
16876            shared.require_controller(connection_id, role)?;
16877            let record = shared
16878                .index_native_session(selection.clone(), display_name)
16879                .await?;
16880            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16881        }
16882        NodeRequest::PreviewSessionRecord {
16883            record_id,
16884            message_limit,
16885        } => {
16886            if role != ClientRole::Operator {
16887                return Err(failure(
16888                    NodeFailureCode::ObserverReadOnly,
16889                    "native session preview requires an operator connection",
16890                ));
16891            }
16892            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16893                .contains(&message_limit)
16894            {
16895                return Err(failure(
16896                    NodeFailureCode::InvalidRequest,
16897                    "native session preview request is invalid",
16898                ));
16899            }
16900            let record = shared.record(&record_id)?;
16901            let working_directory = shared.workspace_root(&record.workspace_id)?;
16902            if !platform::roots_equal(
16903                &working_directory,
16904                windows_path_text(&record.canonical_root),
16905            ) {
16906                return Err(failure(
16907                    NodeFailureCode::SessionRecordConflict,
16908                    "managed session workspace root changed; refusing to preview another directory",
16909                ));
16910            }
16911            let identity = record.provider_session.clone().ok_or_else(|| {
16912                failure(
16913                    NodeFailureCode::BackendOperationFailed,
16914                    "native session preview is unavailable for this session record",
16915                )
16916            })?;
16917            if identity.key != ProviderSessionKey::SessionId {
16918                return Err(failure(
16919                    NodeFailureCode::BackendOperationFailed,
16920                    "native session preview is unavailable for this session identity",
16921                ));
16922            }
16923            let preview = shared
16924                .preview_native_session_by_session_id(
16925                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16926                )
16927                .await?;
16928            if preview.session_id != identity.id {
16929                return Err(failure(
16930                    NodeFailureCode::SessionRecordConflict,
16931                    "native session preview identity changed while loading",
16932                ));
16933            }
16934            shared.revalidate_session_record_preview(&record, &identity)?;
16935            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16936            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16937                record_id: record_id.clone(),
16938                summary: SessionHistorySummaryV1::from(&preview),
16939            });
16940            Ok(NodeResponse::SessionRecordPreviewed {
16941                record_id,
16942                preview,
16943            })
16944        }
16945        NodeRequest::AcquireController { lease_ms } => {
16946            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16947            Ok(NodeResponse::Controller { controller: Some(controller) })
16948        }
16949        NodeRequest::ReleaseController => {
16950            shared.release_controller(connection_id);
16951            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16952        }
16953        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16954            shared.require_controller(connection_id, role)?;
16955            let root = require_windows_path(root)?;
16956            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16957            let workspace = shared.register_workspace(workspace_id, root).await?;
16958            Ok(NodeResponse::WorkspaceRegistered { workspace })
16959        }
16960        NodeRequest::CreateStandaloneWorkspace {
16961            workspace_id,
16962            root,
16963            initial_branch,
16964        } => {
16965            shared.require_controller(connection_id, role)?;
16966            let root = require_windows_path(root)?;
16967            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16968            let workspace = shared
16969                .create_standalone_workspace(workspace_id, root, initial_branch)
16970                .await?;
16971            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16972        }
16973        NodeRequest::UnregisterWorkspace { workspace_id } => {
16974            shared.require_controller(connection_id, role)?;
16975            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16976            shared.unregister_workspace(&workspace_id)?;
16977            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16978        }
16979        NodeRequest::CreateWorktree {
16980            source_workspace_id,
16981            workspace_id,
16982            target_root,
16983            branch,
16984            base,
16985        } => {
16986            shared.require_controller(connection_id, role)?;
16987            let target_root = require_windows_path(target_root)?;
16988            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16989            let (worktree, workspace) = shared
16990                .create_worktree(
16991                    source_workspace_id,
16992                    workspace_id,
16993                    target_root,
16994                    branch,
16995                    base,
16996                )
16997                .await?;
16998            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16999        }
17000        NodeRequest::RemoveWorktree {
17001            source_workspace_id,
17002            target_root,
17003        } => {
17004            shared.require_controller(connection_id, role)?;
17005            let native_target_root = require_windows_path(target_root.clone())?;
17006            shared.reject_managed_reservation(None, Some(&native_target_root))?;
17007            let workspace_id = shared
17008                .remove_worktree(source_workspace_id, native_target_root)
17009                .await?;
17010            Ok(NodeResponse::WorktreeRemoved {
17011                target_root,
17012                workspace_id,
17013            })
17014        }
17015        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17016            shared.require_controller(connection_id, role)?;
17017            if !terminal_size.is_valid() {
17018                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17019            }
17020            if let Some(prompt) = initial_prompt.as_deref() {
17021                validate_node_text("spawn initial prompt", prompt)?;
17022            }
17023            match shared
17024                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17025                .await
17026            {
17027                Ok(session) => {
17028                    shared.mark_spawn_provisional(session.clone());
17029                    Ok(NodeResponse::SpawnAccepted { session })
17030                }
17031                Err(err) => {
17032                    tracing::warn!(
17033                        %provider,
17034                        %workspace_id,
17035                        ?mode,
17036                        code = ?err.code,
17037                        message = %err.message,
17038                        "session spawn refused"
17039                    );
17040                    Err(err)
17041                }
17042            }
17043        }
17044        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17045            shared.require_controller(connection_id, role)?;
17046            if !terminal_size.is_valid() {
17047                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17048            }
17049            if let Some(prompt) = initial_prompt.as_deref() {
17050                validate_node_text("spawn initial prompt", prompt)?;
17051            }
17052            let plan = backend_paths::plan_launch_options(
17053                provider.as_str(),
17054                mode,
17055                options.model.is_some(),
17056                options.effort.is_some(),
17057            )
17058            .map_err(|r| {
17059                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17060                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17061            })?;
17062            let selection = spawn_selection(&options)?;
17063            let nick_model = selection.as_ref().map(|s| s.model.clone());
17064            let pty_selection = match plan {
17065                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17066                _ => None,
17067            };
17068            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17069                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17070                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17071                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17072                        .await
17073                }).await
17074            }).await;
17075            match spawned {
17076                Ok(session) => {
17077                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17078                        shared
17079                            .pending_acp_options
17080                            .lock()
17081                            .unwrap_or_else(|p| p.into_inner())
17082                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17083                    }
17084                    shared.mark_spawn_provisional(session.clone());
17085                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17086                    Ok(NodeResponse::SpawnAccepted { session })
17087                }
17088                Err(err) => {
17089                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17090                    Err(err)
17091                }
17092            }
17093        }
17094        NodeRequest::SpawnSpec { spec } => {
17095            shared.require_controller(connection_id, role)?;
17096            let receipt = shared.spawn_from_spec(spec).await?;
17097            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17098        }
17099        NodeRequest::SpawnManagedWorktree { request } => {
17100            shared.require_controller(connection_id, role)?;
17101            let receipt = shared.spawn_managed_worktree(request).await?;
17102            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17103        }
17104        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17105            shared.require_controller(connection_id, role)?;
17106            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17107            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17108        }
17109        NodeRequest::CleanupManagedWorktree { lease_id } => {
17110            shared.require_controller(connection_id, role)?;
17111            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17112            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17113        }
17114        NodeRequest::DiscoverHistory { session, limit } => {
17115            controlled_session(shared, connection_id, role, &session)?;
17116            let candidates = shared.discover_history(&session, limit).await?;
17117            Ok(NodeResponse::HistoryDiscovered {
17118                session,
17119                candidates,
17120            })
17121        }
17122        NodeRequest::LoadHistory {
17123            session,
17124            candidate_id,
17125        } => {
17126            controlled_session(shared, connection_id, role, &session)?;
17127            let loaded = shared
17128                .load_history(&session, candidate_id)
17129                .await?;
17130            Ok(NodeResponse::HistoryLoaded {
17131                session,
17132                session_id: loaded.session_id,
17133                message_count: loaded.message_count,
17134                completed_turn_count: loaded.completed_turn_count,
17135            })
17136        }
17137        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17138            shared.require_controller(connection_id, role)?;
17139            let context = shared
17140                .export_context_pack_for_session_record(&record_id, &session, false)
17141                .await?;
17142            Ok(NodeResponse::ContextPackForSessionRecordExported {
17143                record_id,
17144                session,
17145                context,
17146            })
17147        }
17148        NodeRequest::ExportContextPack { session } => {
17149            controlled_session(shared, connection_id, role, &session)?;
17150            let context = shared.export_context_pack(&session).await?;
17151            Ok(NodeResponse::ContextPackExported { context })
17152        }
17153        NodeRequest::ForgetContextPack { context_id } => {
17154            shared.require_controller(connection_id, role)?;
17155            shared.forget_context_pack(&context_id)?;
17156            Ok(NodeResponse::ContextPackForgotten { context_id })
17157        }
17158        NodeRequest::ResolveDurableContextPack { context_id } => {
17159            // Read-only, idempotent, no controller lease required: this lets
17160            // a caller preflight "is this durably exported pack still here"
17161            // without holding the Node's mutation controller.
17162            let context = shared
17163                .context_catalog
17164                .read()
17165                .unwrap_or_else(|poisoned| poisoned.into_inner())
17166                .get(&context_id)
17167                .map(|pack| pack.receipt().clone())
17168                .ok_or_else(|| {
17169                    failure(
17170                        NodeFailureCode::UnknownContextPack,
17171                        "context pack is not present",
17172                    )
17173                })?;
17174            Ok(NodeResponse::DurableContextPackResolved { context })
17175        }
17176        NodeRequest::ReadContextPack { digest } => {
17177            // Read-only fetch of the pack's own bytes, keyed by content digest
17178            // rather than the caller's `SpawnContextId`: same no-controller-lease
17179            // rationale as `ResolveDurableContextPack` above, one step further
17180            // down the same catalog.
17181            let (id, byte_len, bytes) = shared
17182                .context_catalog
17183                .read()
17184                .unwrap_or_else(|poisoned| poisoned.into_inner())
17185                .get_by_digest(&digest)
17186                .map(|pack| {
17187                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17188                })
17189                .ok_or_else(|| {
17190                    failure(
17191                        NodeFailureCode::UnknownContextPack,
17192                        &format!("context pack {digest} not on this node"),
17193                    )
17194                })?;
17195            tracing::debug!(
17196                digest = digest.as_str(),
17197                byte_len,
17198                "read context pack bytes",
17199            );
17200            Ok(NodeResponse::ContextPackBytesRead {
17201                pack: ContextPackBytesRead {
17202                    digest,
17203                    id,
17204                    byte_len,
17205                    bytes,
17206                },
17207            })
17208        }
17209        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17210            let provider = controlled_session(shared, connection_id, role, &session)?;
17211            if let Some(prompt) = initial_prompt.as_deref() {
17212                validate_node_text("resume initial prompt", prompt)?;
17213            }
17214            let runtime_requirement = if initial_prompt.is_some() {
17215                ProviderRuntimeRequirement::ResumeWithPrompt
17216            } else {
17217                ProviderRuntimeRequirement::Resume
17218            };
17219            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17220            let runtime_policy = shared
17221                .admit_provider_runtime(&provider, runtime_requirement)
17222                .await?;
17223            let working_directory = shared.bound_workspace_root(&session)?;
17224            let request = ResumeLaunchRequest {
17225                working_directory,
17226                terminal_size,
17227                initial_prompt,
17228            };
17229            request
17230                .validate()
17231                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17232            let command = shared.prepare_command(ControlCommand::Resume {
17233                instance_id: session.session.instance_id,
17234                target: ResumeTarget::CurrentProvider,
17235                runtime_policy,
17236                request,
17237            });
17238            shared.arm_resume(&session, command.id, runtime_policy)?;
17239            let dispatch = shared.dispatch_envelope(command);
17240            if let Err(error) = dispatch {
17241                shared.clear_armed_resume(&session);
17242                return Err(error);
17243            }
17244            Ok(NodeResponse::Accepted)
17245        }
17246        NodeRequest::RenameSessionRecord {
17247            record_id,
17248            display_name,
17249        } => {
17250            shared.require_controller(connection_id, role)?;
17251            let record = shared.rename_session_record(&record_id, display_name)?;
17252            Ok(NodeResponse::SessionRecordUpdated { record })
17253        }
17254        NodeRequest::SetSessionTask {
17255            record_id,
17256            expected_revision,
17257            target,
17258        } => {
17259            shared.require_controller(connection_id, role)?;
17260            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17261            Ok(NodeResponse::SessionRecordUpdated { record })
17262        }
17263        NodeRequest::IndexProviderSession {
17264            workspace_id,
17265            provider,
17266            identity,
17267            display_name,
17268        } => {
17269            shared.require_controller(connection_id, role)?;
17270            let record = shared.index_provider_session(
17271                workspace_id,
17272                provider,
17273                identity,
17274                display_name,
17275            )?;
17276            Ok(NodeResponse::ProviderSessionIndexed { record })
17277        }
17278        NodeRequest::ResumeSessionRecord {
17279            record_id,
17280            terminal_size,
17281            initial_prompt,
17282        } => {
17283            shared.require_controller(connection_id, role)?;
17284            if let Some(prompt) = initial_prompt.as_deref() {
17285                validate_node_text("managed resume initial prompt", prompt)?;
17286            }
17287            let (record, session) = shared
17288                .resume_session_record(
17289                    &record_id,
17290                    terminal_size,
17291                    initial_prompt,
17292                )
17293                .await?;
17294            Ok(NodeResponse::SessionRecordResumed { record, session })
17295        }
17296        NodeRequest::ForgetSessionRecord { record_id } => {
17297            shared.require_controller(connection_id, role)?;
17298            shared.forget_session_record(&record_id).await?;
17299            Ok(NodeResponse::SessionRecordForgotten { record_id })
17300        }
17301        NodeRequest::Prompt { session, text } => {
17302            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17303            validate_node_text("prompt", &text)?;
17304            shared.require_session_runtime_policy(
17305                &session,
17306                ProviderRuntimeRequirement::SemanticPrompt,
17307            )?;
17308            let framing = prompt_framing(&agent_id);
17309            shared
17310                .dispatch_input_bounded(
17311                    &session,
17312                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17313                )
17314                .await?;
17315            Ok(NodeResponse::Accepted)
17316        }
17317        NodeRequest::Paste { session, text } => {
17318            controlled_session(shared, connection_id, role, &session)?;
17319            validate_node_text("paste", &text)?;
17320            shared.require_session_runtime_policy(
17321                &session,
17322                ProviderRuntimeRequirement::SemanticPrompt,
17323            )?;
17324            shared
17325                .dispatch_input_bounded(
17326                    &session,
17327                    InputAction::InsertDraft(PromptPayload {
17328                        text,
17329                        framing: PromptFraming::BracketedPaste,
17330                    }),
17331                )
17332                .await?;
17333            Ok(NodeResponse::Accepted)
17334        }
17335        NodeRequest::Input { session, text } => {
17336            controlled_session(shared, connection_id, role, &session)?;
17337            validate_node_text("terminal input", &text)?;
17338            shared
17339                .dispatch_input_bounded(
17340                    &session,
17341                    InputAction::TerminalText(TerminalText { text }),
17342                )
17343                .await?;
17344            Ok(NodeResponse::Accepted)
17345        }
17346        NodeRequest::TerminalBytes { session, bytes } => {
17347            controlled_session(shared, connection_id, role, &session)?;
17348            shared.sessionize_spawn(session.session.instance_id);
17349            let action = terminal_bytes_action(bytes)?;
17350            shared
17351                .dispatch_input_bounded(&session, action)
17352                .await?;
17353            Ok(NodeResponse::Accepted)
17354        }
17355        NodeRequest::TerminalControl { session, control } => {
17356            controlled_session(shared, connection_id, role, &session)?;
17357            shared
17358                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17359                .await?;
17360            Ok(NodeResponse::Accepted)
17361        }
17362        NodeRequest::Resize { session, size } => {
17363            controlled_session(shared, connection_id, role, &session)?;
17364            shared.sessionize_spawn(session.session.instance_id);
17365            if !size.is_valid() {
17366                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17367            }
17368            shared.dispatch_resize_bounded(&session, size).await?;
17369            Ok(NodeResponse::Accepted)
17370        }
17371        NodeRequest::Interrupt { session } => {
17372            controlled_session(shared, connection_id, role, &session)?;
17373            shared
17374                .dispatch_input_bounded(
17375                    &session,
17376                    InputAction::TerminalControl(TerminalControl::Interrupt),
17377                )
17378                .await?;
17379            Ok(NodeResponse::Accepted)
17380        }
17381        NodeRequest::Stop { session, force } => {
17382            controlled_session(shared, connection_id, role, &session)?;
17383            shared.sessionize_spawn(session.session.instance_id);
17384            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17385            shared.retire_session_grok_leader(session.session.instance_id);
17386            Ok(NodeResponse::Accepted)
17387        }
17388        NodeRequest::Remove { session } => {
17389            controlled_session(shared, connection_id, role, &session)?;
17390            shared.remove_session(&session).await?;
17391            Ok(NodeResponse::Accepted)
17392        }
17393        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17394            shared.require_controller(connection_id, role)?;
17395            let snapshot = shared.require_acp_session(&session)?;
17396            let interaction = snapshot
17397                .provider
17398                .interactions
17399                .iter()
17400                .find(|interaction| {
17401                    interaction_correlation(
17402                        session.session.instance_id,
17403                        session.session.generation,
17404                        interaction.id.0,
17405                    ) == correlation_id
17406                })
17407                .ok_or_else(|| failure(
17408                    NodeFailureCode::InvalidRequest,
17409                    "unknown interaction correlation",
17410                ))?;
17411            if interaction.status != ProviderInteractionStatus::Pending {
17412                return Err(failure(
17413                    NodeFailureCode::BackendOperationFailed,
17414                    "interaction has already been resolved",
17415                ));
17416            }
17417            response
17418                .validate_for(interaction.interaction_kind)
17419                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17420            let interaction_id = interaction.id;
17421            let response_kind = response.kind();
17422            let response_outcome = response.outcome();
17423            shared.dispatch(ControlCommand::ResolveInteraction {
17424                instance_id: session.session.instance_id,
17425                generation: session.session.generation,
17426                interaction_id,
17427                response,
17428            })?;
17429            // The one line that lets an operator compare what THIS node
17430            // recorded against what the provider itself reports doing --
17431            // measured live on a kimi run where our side resolved `true`
17432            // while the agent reported "rejected" and neither layer had a
17433            // line to line up against the other.
17434            tracing::info!(
17435                node_id = %shared.node_id,
17436                session = ?session.session,
17437                correlation_id = %correlation_id,
17438                interaction_id = interaction_id.0,
17439                response_kind = ?response_kind,
17440                outcome = ?response_outcome,
17441                "interaction resolved",
17442            );
17443            Ok(NodeResponse::Accepted)
17444        }
17445        NodeRequest::SetSessionMode { session, mode_id } => {
17446            shared.require_controller(connection_id, role)?;
17447            shared.require_acp_session(&session)?;
17448            shared.dispatch(ControlCommand::SetSessionMode {
17449                instance_id: session.session.instance_id,
17450                mode_id,
17451            })?;
17452            Ok(NodeResponse::Accepted)
17453        }
17454        NodeRequest::SetSessionConfigOption {
17455            session,
17456            option_id,
17457            value_json,
17458        } => {
17459            shared.require_controller(connection_id, role)?;
17460            shared.require_acp_session(&session)?;
17461            shared.dispatch(ControlCommand::SetSessionConfigOption {
17462                instance_id: session.session.instance_id,
17463                option_id,
17464                value_json,
17465            })?;
17466            Ok(NodeResponse::Accepted)
17467        }
17468        NodeRequest::SetSessionModel { session, model_id } => {
17469            shared.require_controller(connection_id, role)?;
17470            shared.require_acp_session(&session)?;
17471            shared.dispatch(ControlCommand::SetSessionModel {
17472                instance_id: session.session.instance_id,
17473                model_id,
17474            })?;
17475            Ok(NodeResponse::Accepted)
17476        }
17477        NodeRequest::Shutdown => {
17478            shared.require_controller(connection_id, role)?;
17479            shared.begin_shutdown_locked().await?;
17480            Ok(NodeResponse::ShuttingDown)
17481        }
17482    }
17483}
17484
17485fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17486    shared.require_controller(connection_id, role)?;
17487    shared.validate_address(session)
17488}
17489
17490/// Selects the sole history candidate matching `provider_session_id` when
17491/// the managed record carries a verified identity, or the sole candidate in
17492/// the discovered set at all when it does not (see
17493/// `export_context_pack_for_session_record_inner`'s identity match). Either
17494/// way, more than one match refuses instead of guessing which one is exact.
17495fn select_history_candidate(
17496    candidates: &[HistoryCandidateSummary],
17497    provider_session_id: Option<&str>,
17498) -> Result<String, NodeFailure> {
17499    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17500        Some(id) => candidate.session_id_hint == id,
17501        None => true,
17502    });
17503    let candidate = matches.next().ok_or_else(|| {
17504        failure(
17505            NodeFailureCode::SessionRecordConflict,
17506            if provider_session_id.is_some() {
17507                "no history candidate matches the managed provider session"
17508            } else {
17509                "no history candidate exists for the managed session"
17510            },
17511        )
17512    })?;
17513    if matches.next().is_some() {
17514        return Err(failure(
17515            NodeFailureCode::SessionRecordConflict,
17516            if provider_session_id.is_some() {
17517                "history identity is ambiguous for the managed provider session"
17518            } else {
17519                "history is ambiguous without a verified provider session identity"
17520            },
17521        ));
17522    }
17523    Ok(candidate.id.clone())
17524}
17525
17526fn resolved_spawn_profile_summary(
17527    profile: &SpawnProfileDefaults,
17528    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17529) -> Option<SpawnProfileSummary> {
17530    let environment_profile = match profile.environment_profile_id.as_ref() {
17531        None => None,
17532        Some(profile_id) => {
17533            let binding = environment_profiles.get(profile_id)?;
17534            if binding.provider != profile.provider
17535                || binding.native_profile_id(profile.mode).is_none()
17536            {
17537                return None;
17538            }
17539            Some(ResolvedEnvironmentProfileReceipt {
17540                profile_id: binding.id.clone(),
17541                profile_revision: binding.revision.clone(),
17542                network_allowlist: None,
17543                browser_profile_id: None,
17544            })
17545        }
17546    };
17547    Some(SpawnProfileSummary {
17548        id: profile.profile_id.clone(),
17549        revision: profile.revision.clone(),
17550        environment_profile,
17551    })
17552}
17553
17554/// True if `current` is an acceptable version of `expected` at export
17555/// revalidation/commit time.
17556///
17557/// When `allow_clean_detachment` is `false` (the on-demand,
17558/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17559/// be byte-identical to `expected` and still actively bound to `session` —
17560/// the original, unchanged invariant.
17561///
17562/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17563/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17564/// export reacts to produces on its own: `state` Live -> Dormant,
17565/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17566/// advancing. Every other field — including `provider_session`, `context`,
17567/// `bundle`, `task_binding` — must still match exactly. A record that changed
17568/// in any other way (rebind, a different active session, an identity
17569/// conflict, capacity eviction) is rejected either way; this widens nothing
17570/// about which sessions are eligible, only how the record's own expected
17571/// detachment is tolerated mid-export.
17572fn session_record_export_target_matches(
17573    expected: &ManagedSessionRecord,
17574    current: &ManagedSessionRecord,
17575    session: &SessionAddress,
17576    allow_clean_detachment: bool,
17577) -> bool {
17578    if current == expected && current.active_session.as_ref() == Some(session) {
17579        return current.state == ManagedSessionState::Live;
17580    }
17581    allow_clean_detachment
17582        && current.state == ManagedSessionState::Dormant
17583        && current.active_session.is_none()
17584        && current.record_id == expected.record_id
17585        && current.display_name == expected.display_name
17586        && current.provider == expected.provider
17587        && current.mode == expected.mode
17588        && current.workspace_id == expected.workspace_id
17589        && current.canonical_root == expected.canonical_root
17590        && current.provider_session == expected.provider_session
17591        && current.environment_profile == expected.environment_profile
17592        && current.bundle == expected.bundle
17593        && current.context_id == expected.context_id
17594        && current.context == expected.context
17595        && current.exported_context == expected.exported_context
17596        && current.task_binding == expected.task_binding
17597        && current.created_at_unix_ms == expected.created_at_unix_ms
17598        && current.last_error == expected.last_error
17599}
17600
17601fn session_record_context_export_binding_is_exact(
17602    expected: &ManagedSessionRecord,
17603    current: &ManagedSessionRecord,
17604    identity: Option<&ProviderSessionIdentity>,
17605    session: &SessionAddress,
17606    binding: &SessionBinding,
17607    provider: &AgentId,
17608    current_root: &str,
17609    allow_clean_detachment: bool,
17610) -> bool {
17611    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17612        && current.provider_session.as_ref() == identity
17613        && current.workspace_id == session.workspace_id
17614        && provider == &current.provider
17615        && binding.workspace_id == session.workspace_id
17616        && binding.generation == session.session.generation
17617        && binding.record_id.as_ref() == Some(&current.record_id)
17618        && platform::roots_equal(
17619            current_root,
17620            windows_path_text(&current.canonical_root),
17621        )
17622}
17623
17624fn session_record_context_export_source_is_usable(
17625    record: &ManagedSessionRecord,
17626    status: &SessionStatus,
17627    allow_clean_detachment: bool,
17628) -> bool {
17629    (record.state == ManagedSessionState::Live
17630        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17631        && context_pack_source_status_is_usable(status)
17632}
17633
17634fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17635    PromptFraming::BracketedPaste
17636}
17637
17638fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17639    if text.len() > MAX_NODE_TEXT_BYTES {
17640        return Err(failure(
17641            NodeFailureCode::InvalidRequest,
17642            &format!(
17643                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17644                text.len(),
17645            ),
17646        ));
17647    }
17648    Ok(())
17649}
17650
17651fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17652    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17653        return Err(failure(
17654            NodeFailureCode::InvalidRequest,
17655            &format!(
17656                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17657                bytes.len(),
17658            ),
17659        ));
17660    }
17661    Ok(())
17662}
17663
17664fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17665    validate_terminal_bytes(&bytes)?;
17666    Ok(InputAction::TerminalBytes(bytes))
17667}
17668
17669fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17670    if root.is_empty()
17671        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17672        || root.chars().any(char::is_control)
17673    {
17674        return Err(failure(
17675            NodeFailureCode::InvalidWorkspaceRoot,
17676            &format!(
17677                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17678            ),
17679        ));
17680    }
17681    Ok(())
17682}
17683
17684fn canonical_standalone_workspace_candidate(
17685    workspace_id: &WorkspaceId,
17686    root: &str,
17687) -> Result<String, NodeServerError> {
17688    let path = Path::new(root);
17689    match std::fs::symlink_metadata(path) {
17690        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17691            .map(|workspace| workspace.canonical_root),
17692        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17693            if !path.is_absolute() {
17694                return Err(NodeServerError::InvalidWorkspaceRoot {
17695                    workspace_id: workspace_id.clone(),
17696                    path: root.to_owned(),
17697                    message: "path is not absolute".to_owned(),
17698                });
17699            }
17700            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17701                workspace_id: workspace_id.clone(),
17702                path: root.to_owned(),
17703                message: "path has no standalone directory name".to_owned(),
17704            })?;
17705            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17706                workspace_id: workspace_id.clone(),
17707                path: root.to_owned(),
17708                message: "path has no parent directory".to_owned(),
17709            })?;
17710            let canonical = std::fs::canonicalize(parent)
17711                .map(|parent| parent.join(name))
17712                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17713                    workspace_id: workspace_id.clone(),
17714                    path: root.to_owned(),
17715                    message: error.to_string(),
17716                })?;
17717            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17718                NodeServerError::InvalidWorkspaceRoot {
17719                    workspace_id: workspace_id.clone(),
17720                    path: path.to_string_lossy().into_owned(),
17721                    message: "canonical path is not valid Unicode".to_owned(),
17722                }
17723            })?;
17724            let canonical_root = platform::normalize_canonical_root(canonical_root);
17725            validate_workspace_root(workspace_id, &canonical_root)?;
17726            Ok(canonical_root)
17727        }
17728        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17729            workspace_id: workspace_id.clone(),
17730            path: root.to_owned(),
17731            message: error.to_string(),
17732        }),
17733    }
17734}
17735
17736fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17737    let Some(revision) = revision else {
17738        return Ok(());
17739    };
17740    if revision.is_empty()
17741        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17742        || revision.chars().any(char::is_control)
17743    {
17744        return Err(failure(
17745            NodeFailureCode::InvalidRequest,
17746            &format!(
17747                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17748            ),
17749        ));
17750    }
17751    Ok(())
17752}
17753
17754fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17755    let code = match error.kind {
17756        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17757        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17758        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17759        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17760        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17761        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17762        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17763    };
17764    failure(code, &error.message)
17765}
17766
17767fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17768    let code = match error.kind {
17769        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17770        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17771        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17772            NodeFailureCode::BackendOperationFailed
17773        }
17774        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17775            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17776        }
17777    };
17778    failure(code, error.message)
17779}
17780
17781fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17782    let (code, message) = match error.kind {
17783        GitWorktreeErrorKind::Invalid => (
17784            NodeFailureCode::InvalidRequest,
17785            "managed Git input is invalid",
17786        ),
17787        GitWorktreeErrorKind::NotRepository => (
17788            NodeFailureCode::NotGitRepository,
17789            "managed source is not a Git repository",
17790        ),
17791        GitWorktreeErrorKind::Conflict => (
17792            NodeFailureCode::WorktreeConflict,
17793            "managed Git identity conflicts with existing repository state",
17794        ),
17795        GitWorktreeErrorKind::Protected => (
17796            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17797            "managed worktree ownership validation failed",
17798        ),
17799        GitWorktreeErrorKind::Dirty => (
17800            NodeFailureCode::WorktreeDirty,
17801            "managed worktree contains uncommitted changes",
17802        ),
17803        GitWorktreeErrorKind::Locked => (
17804            NodeFailureCode::WorktreeLocked,
17805            "managed worktree is locked",
17806        ),
17807        GitWorktreeErrorKind::Failed => (
17808            NodeFailureCode::BackendOperationFailed,
17809            "managed Git operation failed",
17810        ),
17811    };
17812    NodeFailure { code, message: message.to_owned() }
17813}
17814
17815fn managed_spawn_receipt(
17816    resolved: &ResolvedSpawnSpec,
17817    incarnation_id: NodeIncarnationId,
17818    session: SessionAddress,
17819    lease: ManagedWorktreeLeaseSnapshot,
17820    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17821    bundle: Option<ResolvedBundleReceipt>,
17822    context: Option<ResolvedContextPackReceipt>,
17823) -> ManagedWorktreeSpawnReceipt {
17824    let mut managed = resolved.clone();
17825    managed.target.worktree_id = Some(lease.workspace_id.clone());
17826    ManagedWorktreeSpawnReceipt {
17827        spawn: managed.receipt_with_materialization(
17828            incarnation_id,
17829            session,
17830            environment_profile,
17831            bundle,
17832            context,
17833        ),
17834        lease,
17835    }
17836}
17837
17838fn validate_selected_worktree(
17839    source_root: &str,
17840    selected_root: &str,
17841    worktrees: &[NativeGitWorktreeSnapshot],
17842) -> Result<(), NodeFailure> {
17843    if !worktrees
17844        .iter()
17845        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17846    {
17847        return Err(failure(
17848            NodeFailureCode::WorktreeProtected,
17849            "source workspace is not an authoritative non-bare Git worktree root",
17850        ));
17851    }
17852    if !worktrees.iter().any(|worktree| {
17853        worktree_paths_equal(&worktree.path, selected_root)
17854            && !worktree.is_main
17855            && !worktree.is_bare
17856    }) {
17857        return Err(failure(
17858            NodeFailureCode::WorktreeProtected,
17859            "selected workspace is not an eligible Git worktree in the source repository",
17860        ));
17861    }
17862    Ok(())
17863}
17864
17865fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17866    let code = match error.kind() {
17867        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17868        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17869            NodeFailureCode::RepositoryPathUnsafe
17870        }
17871        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17872        WorkspaceFileReadErrorKind::NotRegularFile => {
17873            NodeFailureCode::RepositoryFileNotRegular
17874        }
17875        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17876            NodeFailureCode::RepositoryFileReadFailed
17877        }
17878        WorkspaceFileReadErrorKind::RevisionConflict => {
17879            NodeFailureCode::RepositoryFileRevisionConflict
17880        }
17881        WorkspaceFileReadErrorKind::AlreadyExists
17882        | WorkspaceFileReadErrorKind::ParentNotFound
17883        | WorkspaceFileReadErrorKind::ParentNotDirectory
17884        | WorkspaceFileReadErrorKind::Canceled => {
17885            NodeFailureCode::RepositoryFileReadFailed
17886        }
17887    };
17888    failure(code, "repository file read failed")
17889}
17890
17891fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17892    let code = match error.kind() {
17893        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17894        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17895            NodeFailureCode::RepositoryPathUnsafe
17896        }
17897        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17898        WorkspaceFileReadErrorKind::NotRegularFile => {
17899            NodeFailureCode::RepositoryFileNotRegular
17900        }
17901        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17902            NodeFailureCode::RepositoryFileWriteFailed
17903        }
17904        WorkspaceFileReadErrorKind::RevisionConflict => {
17905            NodeFailureCode::RepositoryFileRevisionConflict
17906        }
17907        WorkspaceFileReadErrorKind::AlreadyExists
17908        | WorkspaceFileReadErrorKind::ParentNotFound
17909        | WorkspaceFileReadErrorKind::ParentNotDirectory
17910        | WorkspaceFileReadErrorKind::Canceled => {
17911            NodeFailureCode::RepositoryFileWriteFailed
17912        }
17913    };
17914    failure(code, "repository file write failed")
17915}
17916
17917async fn settle_workspace_entry_create<T>(
17918    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17919    commit_state: Arc<AtomicU8>,
17920    deadline: Duration,
17921    timeout_message: &'static str,
17922    task_failure_message: &'static str,
17923) -> Result<T, NodeFailure> {
17924    let joined = match timeout(deadline, &mut task).await {
17925        Ok(joined) => joined,
17926        Err(_) => {
17927            if commit_state
17928                .compare_exchange(
17929                    WORKSPACE_ENTRY_CREATE_PENDING,
17930                    WORKSPACE_ENTRY_CREATE_CANCELED,
17931                    Ordering::AcqRel,
17932                    Ordering::Acquire,
17933                )
17934                .is_ok()
17935            {
17936                return Err(failure(
17937                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17938                    timeout_message,
17939                ));
17940            }
17941            debug_assert_eq!(
17942                commit_state.load(Ordering::Acquire),
17943                WORKSPACE_ENTRY_CREATE_COMMITTING,
17944            );
17945            task.await
17946        }
17947    };
17948    joined
17949        .map_err(|_| {
17950            failure(
17951                NodeFailureCode::RepositoryEntryCreateFailed,
17952                task_failure_message,
17953            )
17954        })?
17955        .map_err(workspace_entry_create_failure)
17956}
17957
17958fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17959    let code = match error.kind() {
17960        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17961        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17962            NodeFailureCode::RepositoryPathUnsafe
17963        }
17964        WorkspaceFileReadErrorKind::AlreadyExists => {
17965            NodeFailureCode::RepositoryEntryAlreadyExists
17966        }
17967        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17968            NodeFailureCode::RepositoryParentNotFound
17969        }
17970        WorkspaceFileReadErrorKind::ParentNotDirectory
17971        | WorkspaceFileReadErrorKind::NotRegularFile => {
17972            NodeFailureCode::RepositoryParentNotDirectory
17973        }
17974        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17975            NodeFailureCode::RepositoryEntryCreateFailed
17976        }
17977        WorkspaceFileReadErrorKind::RevisionConflict => {
17978            NodeFailureCode::RepositoryEntryCreateFailed
17979        }
17980        WorkspaceFileReadErrorKind::Canceled => {
17981            NodeFailureCode::RepositoryEntryCreateTimedOut
17982        }
17983    };
17984    failure(code, "repository entry create failed")
17985}
17986
17987fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17988    let value = digest(&SHA256, bytes)
17989        .as_ref()
17990        .iter()
17991        .map(|byte| format!("{byte:02x}"))
17992        .collect();
17993    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17994}
17995
17996fn managed_spawn_request_digest_v2(
17997    request: &ManagedWorktreeSpawnRequestV2,
17998) -> Result<String, NodeFailure> {
17999    let encoded = serde_json::to_vec(request).map_err(|_| {
18000        failure(
18001            NodeFailureCode::InvalidRequest,
18002            "managed worktree V2 request could not be canonicalized",
18003        )
18004    })?;
18005    let mut value = String::with_capacity(71);
18006    value.push_str("sha256:");
18007    for byte in digest(&SHA256, &encoded).as_ref() {
18008        use std::fmt::Write as _;
18009        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18010    }
18011    Ok(value)
18012}
18013
18014fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18015    let code = match error {
18016        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18017        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18018        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18019        NativeSessionPreviewError::UnsupportedProvider
18020        | NativeSessionPreviewError::PreviewUnavailable
18021        | NativeSessionPreviewError::SessionNotFound
18022        | NativeSessionPreviewError::AmbiguousSession => {
18023            NodeFailureCode::BackendOperationFailed
18024        }
18025    };
18026    failure(code, "native session preview failed")
18027}
18028
18029fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18030    let code = match error.kind() {
18031        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18032        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18033    };
18034    failure(code, "host directory browse failed")
18035}
18036
18037fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18038    let code = match error {
18039        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18040        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18041        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18042            NodeFailureCode::DeliveryStageConflict
18043        }
18044        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18045        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18046            NodeFailureCode::DeliveryChunkOutOfOrder
18047        }
18048        DeliveryStoreError::BlobDigestMismatch => {
18049            NodeFailureCode::DeliveryBlobDigestMismatch
18050        }
18051        DeliveryStoreError::BundleDigestMismatch => {
18052            NodeFailureCode::DeliveryBundleDigestMismatch
18053        }
18054        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18055        DeliveryStoreError::Unavailable
18056        | DeliveryStoreError::Corrupt
18057        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18058    };
18059    failure(code, "delivery operation failed")
18060}
18061
18062fn validate_workspace_root(
18063    workspace_id: &WorkspaceId,
18064    canonical_root: &str,
18065) -> Result<(), NodeServerError> {
18066    #[cfg(windows)]
18067    if canonical_root.starts_with(r"\\") {
18068        return Err(NodeServerError::InvalidWorkspaceRoot {
18069            workspace_id: workspace_id.clone(),
18070            path: canonical_root.to_owned(),
18071            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18072        });
18073    }
18074    if !platform::workspace_root_supported(canonical_root)
18075        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18076    {
18077        return Err(NodeServerError::InvalidWorkspaceRoot {
18078            workspace_id: workspace_id.clone(),
18079            path: canonical_root.to_owned(),
18080            message: "canonical path must be absolute, bounded, and free of control characters"
18081                .to_owned(),
18082        });
18083    }
18084    Ok(())
18085}
18086
18087fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18088    NodeFailure {
18089        code,
18090        message: node_failure_category(code).to_owned(),
18091    }
18092}
18093
18094fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18095    let code = match error {
18096        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18097        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18098        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18099        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18100        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18101        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18102        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18103        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18104        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18105    };
18106    failure(code, "harness MCP proxy operation failed")
18107}
18108
18109/// Numbers each harness-MCP overlay this process constructs, in
18110/// construction order, so two overlays built back to back in one node process
18111/// still get distinct trace files.
18112static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18113
18114/// Builds the optional trace environment entry of one harness-MCP overlay.
18115/// The caller's launch description names both the entry and the node-process
18116/// variable that opts in: when that variable holds a non-empty directory, the
18117/// entry names a trace file under it. Returns `None` when the launch has no
18118/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18119/// the directory; that is the operator's job.
18120fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18121    let trace = launch.trace.as_ref()?;
18122    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18123    if trace_dir.is_empty() {
18124        return None;
18125    }
18126    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18127    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18128    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18129}
18130
18131fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18132    let remaining = deadline.saturating_duration_since(Instant::now());
18133    if remaining.is_zero() {
18134        Err(failure(
18135            NodeFailureCode::SpawnDeadlineExceeded,
18136            "spawn deadline elapsed",
18137        ))
18138    } else {
18139        Ok(remaining)
18140    }
18141}
18142
18143fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18144    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18145    match deadline {
18146        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18147        None => Ok(bounded),
18148    }
18149}
18150
18151fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18152    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18153        failure(
18154            NodeFailureCode::SpawnDeadlineExceeded,
18155            "spawn dispatch exceeded the spawn deadline",
18156        )
18157    } else {
18158        error
18159    }
18160}
18161
18162fn spawn_runtime_capabilities(
18163    capabilities: &SpawnRequiredCapabilities,
18164) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18165    capabilities
18166        .iter()
18167        .map(|capability| match capability.as_str() {
18168            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18169            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18170            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18171            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18172                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18173            }
18174            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18175            _ => Err(failure(
18176                NodeFailureCode::UnsupportedSpawnCapability,
18177                "spawn required capability is unknown",
18178            )),
18179        })
18180        .collect()
18181}
18182
18183fn persistence_failure(_error: io::Error) -> NodeFailure {
18184    NodeFailure {
18185        code: NodeFailureCode::BackendOperationFailed,
18186        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18187    }
18188}
18189
18190fn node_failure_category(code: NodeFailureCode) -> &'static str {
18191    match code {
18192        NodeFailureCode::InvalidRequest => "invalid-request",
18193        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18194        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18195        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18196        NodeFailureCode::ReservationConflict => "reservation-conflict",
18197        NodeFailureCode::ReservationExpired => "reservation-expired",
18198        NodeFailureCode::BindingMismatch => "binding-mismatch",
18199        NodeFailureCode::NotActivated => "not-activated",
18200        NodeFailureCode::CallNotFound => "call-not-found",
18201        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18202        NodeFailureCode::ResponseTooLarge => "response-too-large",
18203        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18204        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18205        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18206        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18207        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18208        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18209        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18210        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18211        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18212        NodeFailureCode::Unauthorized => "unauthorized",
18213        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18214        NodeFailureCode::ControllerBusy => "controller-busy",
18215        NodeFailureCode::ControllerRequired => "controller-required",
18216        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18217        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18218        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18219        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18220        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18221        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18222        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18223        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18224        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18225        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18226        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18227        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18228        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18229        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18230        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18231        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18232        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18233        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18234        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18235        NodeFailureCode::GitReadFailed => "git-read-failed",
18236        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18237        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18238        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18239        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18240        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18241        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18242        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18243        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18244        NodeFailureCode::TurnInFlight => "turn-in-flight",
18245        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18246        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18247        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18248            "unsupported-network-allowlist-mapping"
18249        }
18250        NodeFailureCode::BrowserStationProbeUnavailable => {
18251            "browser-station-probe-unavailable"
18252        }
18253        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18254        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18255        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18256            "environment-profile-binding-mismatch"
18257        }
18258        NodeFailureCode::UnknownBundle => "unknown-bundle",
18259        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18260        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18261        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18262        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18263        NodeFailureCode::ContextPackMaterializationFailed => {
18264            "context-pack-materialization-failed"
18265        }
18266        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18267        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18268        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18269        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18270        NodeFailureCode::LastWorkspace => "last-workspace",
18271        NodeFailureCode::NotGitRepository => "not-git-repository",
18272        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18273        NodeFailureCode::WorktreeProtected => "worktree-protected",
18274        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18275        NodeFailureCode::WorktreeLocked => "worktree-locked",
18276        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18277        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18278        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18279        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18280            "managed-worktree-profile-revision-mismatch"
18281        }
18282        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18283        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18284            "standalone-workspace-recovery-required"
18285        }
18286        NodeFailureCode::UnknownSession => "unknown-session",
18287        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18288        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18289        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18290        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18291        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18292        NodeFailureCode::WorkspaceRegistrationRequired => {
18293            "workspace-registration-required"
18294        }
18295        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18296        NodeFailureCode::StaleGeneration => "stale-generation",
18297        NodeFailureCode::BackendBusy => "backend-busy",
18298        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18299        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18300        NodeFailureCode::ShuttingDown => "shutting-down",
18301    }
18302}
18303
18304fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18305    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18306}
18307
18308fn durable_state_load_error(error: io::Error) -> NodeServerError {
18309    let category = match session_registry::state_load_refusal(&error) {
18310        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18311            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18312        }
18313        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18314            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18315        }
18316        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18317            DURABLE_STATE_CONFLICT_ERROR
18318        }
18319        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18320    };
18321    durable_state_server_error(error, category)
18322}
18323
18324fn unix_time_ms() -> u64 {
18325    std::time::SystemTime::now()
18326        .duration_since(std::time::UNIX_EPOCH)
18327        .unwrap_or_default()
18328        .as_millis()
18329        .min(u64::MAX as u128) as u64
18330}
18331
18332fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18333    if record.provider_session.is_some() {
18334        ManagedSessionState::Dormant
18335    } else {
18336        ManagedSessionState::Unavailable
18337    }
18338}
18339
18340fn active_record_state(
18341    identity_admitted: bool,
18342    provider_session_is_known: bool,
18343) -> ManagedSessionState {
18344    if identity_admitted && !provider_session_is_known {
18345        ManagedSessionState::IdentityPending
18346    } else {
18347        ManagedSessionState::Live
18348    }
18349}
18350
18351fn managed_worktree_record_holders(
18352    records: &[ManagedSessionRecord],
18353) -> Vec<(SessionRecordId, WorkspaceId)> {
18354    records
18355        .iter()
18356        .filter(|record| record.provider_session.is_some())
18357        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18358        .collect()
18359}
18360
18361fn managed_spawn_record_ownership(
18362    binding: &SessionBinding,
18363    effective_runtime_policy: ProviderRuntimePolicy,
18364) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18365    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18366    if effective_runtime_policy.provider_session_identity {
18367        (binding.record_id.clone(), None)
18368    } else {
18369        (None, binding.record_id.clone())
18370    }
18371}
18372
18373#[derive(Debug, Error)]
18374pub enum NodeServerError {
18375    #[error("node endpoint must be a bounded absolute local transport path")]
18376    InvalidEndpoint,
18377    #[error("node access token must contain 1..=4096 bytes")]
18378    InvalidAccessToken,
18379    #[error("node HTTP observer must listen on a loopback address: {0}")]
18380    InvalidApiListen(std::net::SocketAddr),
18381    #[error("node browser bridge must listen on a loopback address: {0}")]
18382    InvalidBridgeListen(std::net::SocketAddr),
18383    #[error("node browser bridge token must contain 1..=4096 bytes")]
18384    InvalidBridgeToken,
18385    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18386    BridgeUnderlayRequiresBridgeListen,
18387    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18388    BridgeUnderlayRequiresBridgeToken,
18389    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18390    InvalidBridgeUnderlayToken,
18391    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18392    InvalidBridgeUnderlayKey,
18393    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18394    InvalidCallHome(std::net::SocketAddr),
18395    #[error("node could not reach its call-home relay: {0}")]
18396    CallHomeTransport(#[source] std::io::Error),
18397    #[error("node durable state path must be an absolute file path: {0}")]
18398    InvalidStatePath(String),
18399    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18400    InvalidHarnessMcpHelper,
18401    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18402    InvalidNetworkAllowlistCatalogPath(String),
18403    #[error("network allowlist catalog could not be read: {0}")]
18404    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18405    #[error("network allowlist catalog line {line}: {message}")]
18406    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18407    #[error("network allowlist catalog: {0}")]
18408    InvalidNetworkAllowlistCatalog(String),
18409    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18410    NetworkAllowlistCatalogCapacity { max: usize },
18411    #[error("the local state directory is unavailable; supply an explicit state path")]
18412    LocalStateDirectoryUnavailable,
18413    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18414    LocalRuntimeDirectoryUnavailable,
18415    #[error("node durable state failed: {0}")]
18416    DurableState(io::Error),
18417    #[error("node requires at least one configured workspace")]
18418    NoWorkspaces,
18419    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18420    InvalidWorkspaceRoot {
18421        workspace_id: WorkspaceId,
18422        path: String,
18423        message: String,
18424    },
18425    #[error("duplicate workspace ID: {0}")]
18426    DuplicateWorkspaceId(WorkspaceId),
18427    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18428    DuplicateWorkspaceRoot {
18429        first: WorkspaceId,
18430        second: WorkspaceId,
18431        root: String,
18432    },
18433    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18434    ManagedWorktreeProfileRequired(WorkspaceId),
18435    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18436    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18437    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18438    DuplicateManagedWorktreeProfile {
18439        workspace_id: WorkspaceId,
18440        profile_id: WorktreeProfileId,
18441    },
18442    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18443    ManagedWorktreeProfileCapacity {
18444        workspace_id: WorkspaceId,
18445        max: usize,
18446    },
18447    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18448    InvalidManagedWorktreeProfile {
18449        workspace_id: WorkspaceId,
18450        profile_id: WorktreeProfileId,
18451        message: String,
18452    },
18453    #[error("duplicate node environment profile: {0}")]
18454    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18455    #[error("node environment profile provider is unavailable: {0}")]
18456    EnvironmentProfileProviderUnavailable(AgentId),
18457    #[error("node environment profile capacity is {max}")]
18458    EnvironmentProfileCapacity { max: usize },
18459    #[error("node environment profiles reuse native profile ID '{0}'")]
18460    DuplicateNativeEnvironmentProfile(String),
18461    #[error("node native environment profile is invalid: {0}")]
18462    NativeEnvironmentProfile(String),
18463    #[error("node session-environment materialization root must be absolute")]
18464    InvalidSessionEnvironmentRoot,
18465    #[error("node session-environment materializer is unavailable")]
18466    SessionEnvironmentMaterializerRequired,
18467    #[error("node session-environment materializer failed to initialize")]
18468    SessionEnvironmentMaterializer,
18469    #[error("node bundle catalog is invalid: {0}")]
18470    BundleCatalog(String),
18471    #[error("node delivery store failed to initialize: {0}")]
18472    DeliveryStore(String),
18473    #[error("node context pack store failed to initialize")]
18474    ContextPackStore,
18475    #[error("active agent registry failed: {0}")]
18476    Registry(String),
18477    #[error("node provider contract manifest is invalid: {0}")]
18478    ProviderContractManifest(String),
18479    #[error("named pipe I/O failed: {0}")]
18480    Io(#[from] io::Error),
18481    #[error("node HTTP observer failed: {0}")]
18482    HttpApi(io::Error),
18483    #[error("node browser bridge failed: {0}")]
18484    BridgeApi(io::Error),
18485    #[error(transparent)]
18486    Frame(#[from] FrameError),
18487    #[error("node handshake failed: {0}")]
18488    Handshake(String),
18489    #[error("node authentication frame was not received before the bounded deadline")]
18490    AuthenticationTimedOut,
18491    #[error("node authentication primitive failed: {0}")]
18492    Authentication(String),
18493    #[error("node incarnation identity generation failed: {0}")]
18494    IncarnationIdentity(String),
18495    #[error("node pre-authentication limiter is closed")]
18496    PreauthClosed,
18497    #[error("node authenticated connection limit was reached")]
18498    AuthenticatedConnectionLimit,
18499    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18500    SignalStreamClosed,
18501    #[error("node stop-all dispatch failed: {0:?}")]
18502    ShutdownDispatch(NodeFailure),
18503    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18504    ShutdownTimedOut { active_native_sessions: usize },
18505}
18506
18507impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18508    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18509        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18510        match error {
18511            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18512            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18513            E::InvalidEntry { line, message } => {
18514                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18515            }
18516            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18517            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18518        }
18519    }
18520}
18521
18522#[cfg(test)]
18523mod agent_stream_blocked_tests {
18524    use super::*;
18525    use gate4agent_types::{
18526        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18527        ProviderSource,
18528    };
18529
18530    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18531        ProviderSource {
18532            family,
18533            binding: AdapterBinding::new(
18534                AdapterId::new(adapter).unwrap(),
18535                "fixture/v1",
18536                AdapterVerification::SyntheticFixture,
18537            )
18538            .unwrap(),
18539        }
18540    }
18541
18542    fn provider_control_event_at(
18543        family: AdapterFamily,
18544        adapter: &str,
18545        source_sequence: u64,
18546        provider_event: ProviderEvent,
18547    ) -> ControlEvent {
18548        ControlEvent {
18549            sequence: 41,
18550            command_id: None,
18551            instance_id: AgentInstanceId(7),
18552            generation: SessionGeneration(3),
18553            event: ControlEventKind::ProviderEvent {
18554                sequence: 8,
18555                source: provider_source(family, adapter),
18556                source_sequence,
18557                event: provider_event,
18558            },
18559        }
18560    }
18561
18562    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18563        provider_control_event_at(family, "fixture", 9, provider_event)
18564    }
18565
18566    fn denied_host_request(
18567        by: ProviderHostDecisionAuthority,
18568        reason: Option<String>,
18569    ) -> ControlEvent {
18570        provider_control_event_at(
18571            AdapterFamily::Acp,
18572            "claude-code",
18573            2,
18574            ProviderEvent::HostRequestObserved {
18575                method: "terminal/create".to_owned(),
18576                params_json: String::new(),
18577                decision: ProviderHostRequestDecision::Denied { by },
18578                outcome: ProviderHostRequestOutcome::Executed,
18579                reason,
18580            },
18581        )
18582    }
18583
18584    fn blocked(
18585        event: &ControlEvent,
18586    ) -> (
18587        Option<String>,
18588        String,
18589        BlockAuthorityV1,
18590        Option<String>,
18591        String,
18592        Option<String>,
18593    ) {
18594        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18595        chunk.validate().expect("minted chunk must validate");
18596        let AgentStreamChunkKindV1::Blocked {
18597            correlation_id,
18598            tool_class,
18599            authority,
18600            reason_kind,
18601            reason,
18602            help,
18603        } = chunk.kind
18604        else {
18605            panic!("expected AgentStreamChunkKindV1::Blocked");
18606        };
18607        (correlation_id, tool_class, authority, reason_kind, reason, help)
18608    }
18609
18610    /// Pins the fleet and every adapter each member declares: the fleet is
18611    /// exactly what `active_registry` says, and no member declares a hook
18612    /// adapter (lifecycle hooks are retired).
18613    #[test]
18614    fn the_enabled_provider_matrix_is_exact() {
18615        let registry = active_registry().unwrap();
18616        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18617        assert_eq!(
18618            providers
18619                .iter()
18620                .map(|contract| contract.provider.as_str())
18621                .collect::<Vec<_>>(),
18622            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18623            vec!["shell", "claude", "codex", "grok", "kimi"],
18624        );
18625        assert!(adapters.iter().all(|contract| {
18626            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18627        }));
18628    }
18629
18630    /// A gate-denied host request, WITH its reason text carried all the way
18631    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18632    #[test]
18633    fn gate_denied_host_request_mints_a_blocked_chunk() {
18634        let gate_text =
18635            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18636        let event = denied_host_request(
18637            ProviderHostDecisionAuthority::Gate,
18638            Some(gate_text.to_owned()),
18639        );
18640        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18641        assert_eq!(chunk.source_sequence, 2);
18642        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18643        assert_eq!(authority, BlockAuthorityV1::HostGate);
18644        assert_eq!(reason, gate_text);
18645        assert_eq!(correlation_id, None);
18646        assert_eq!(reason_kind, None);
18647        assert_eq!(help, None);
18648        assert_eq!(tool_class, "Write");
18649    }
18650
18651    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.";
18652
18653    /// The owner's classifier sample, verbatim, arriving as a
18654    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18655    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18656    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18657    /// and the guidance tail as `help`.
18658    #[test]
18659    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18660        let event = provider_control_event(
18661            AdapterFamily::Acp,
18662            ProviderEvent::ToolCompleted {
18663                id: "toolu_9".to_owned(),
18664                output: CLASSIFIER_SAMPLE.to_owned(),
18665                is_error: true,
18666                duration_ms: None,
18667                agent_id: None,
18668                non_execution_kind: Some("permission-rule".to_owned()),
18669            },
18670        );
18671        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18672        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18673        assert_eq!(reason, "Blocked by classifier");
18674        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18675        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18676        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18677    }
18678
18679    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18680    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18681    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18682    /// vendor code as `reason_kind`.
18683    #[test]
18684    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18685        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.";
18686        let event = provider_control_event(
18687            AdapterFamily::Acp,
18688            ProviderEvent::SessionEnded {
18689                result: "JSON-RPC error -32603: Internal error".to_owned(),
18690                cost_usd: None,
18691                is_error: true,
18692                stop_reason: Some(ProviderStopReason::ProviderError {
18693                    code: -32603,
18694                    message: message.to_owned(),
18695                    vendor_code: Some("usageLimitExceeded".to_owned()),
18696                }),
18697            },
18698        );
18699        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18700        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18701        assert_eq!(reason, message);
18702        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18703        assert_eq!(tool_class, "Session");
18704    }
18705
18706    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18707    /// `authority: ProviderRefusal`.
18708    #[test]
18709    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18710        let event = provider_control_event(
18711            AdapterFamily::Acp,
18712            ProviderEvent::SessionEnded {
18713                result: "refusal".to_owned(),
18714                cost_usd: None,
18715                is_error: true,
18716                stop_reason: Some(ProviderStopReason::Refusal),
18717            },
18718        );
18719        let (_, _, authority, _, _, _) = blocked(&event);
18720        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18721    }
18722
18723    /// A failed tool completion with no typed `non_execution_kind` -- or one
18724    /// that only says the turn stopped -- is a plain execution failure, never a
18725    /// block; nor is an agent's own narrative claiming one.
18726    #[test]
18727    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18728        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18729            let event = provider_control_event(
18730                AdapterFamily::Acp,
18731                ProviderEvent::ToolCompleted {
18732                    id: "toolu_1".to_owned(),
18733                    output: "command exited with status 1".to_owned(),
18734                    is_error: true,
18735                    duration_ms: Some(12),
18736                    agent_id: None,
18737                    non_execution_kind,
18738                },
18739            );
18740            assert!(agent_stream_chunk(&event).is_none());
18741        }
18742        let narrative = provider_control_event(
18743            AdapterFamily::Acp,
18744            ProviderEvent::ToolCompleted {
18745                id: "toolu_2".to_owned(),
18746                output: "the harness blocked the write, approval needed".to_owned(),
18747                is_error: false,
18748                duration_ms: Some(3),
18749                agent_id: None,
18750                non_execution_kind: None,
18751            },
18752        );
18753        assert!(agent_stream_chunk(&narrative).is_none());
18754    }
18755
18756    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18757    /// execution outcome: a granted request that failed while running was
18758    /// still authorized.
18759    #[test]
18760    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18761        for (decision, outcome) in [
18762            (
18763                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18764                ProviderHostRequestOutcome::Executed,
18765            ),
18766            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18767            (
18768                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18769                ProviderHostRequestOutcome::Failed {
18770                    error: "terminal/create spawn failed: os error 3".to_owned(),
18771                },
18772            ),
18773        ] {
18774            let event = provider_control_event_at(
18775                AdapterFamily::Acp,
18776                "claude-code",
18777                3,
18778                ProviderEvent::HostRequestObserved {
18779                    method: "fs/read_text_file".to_owned(),
18780                    params_json: String::new(),
18781                    decision: decision.clone(),
18782                    outcome,
18783                    reason: None,
18784                },
18785            );
18786            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18787        }
18788    }
18789
18790    /// A deferred `session/request_permission` resolved by an operator or an
18791    /// expired deadline carries no reason text -- the chunk falls back to a
18792    /// categorical sentence naming the authority rather than inventing detail.
18793    #[test]
18794    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18795        for (by, expected_authority, expected_reason) in [
18796            (
18797                ProviderHostDecisionAuthority::Operator,
18798                BlockAuthorityV1::Operator,
18799                "declined by the operator",
18800            ),
18801            (
18802                ProviderHostDecisionAuthority::DeadlinePolicy,
18803                BlockAuthorityV1::HostDeadline,
18804                "denied by host policy after the operator deadline expired",
18805            ),
18806        ] {
18807            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18808            assert_eq!(authority, expected_authority);
18809            assert_eq!(reason, expected_reason);
18810        }
18811    }
18812
18813    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18814    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18815    /// outright rather than truncating.
18816    #[test]
18817    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18818        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18819        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18820            ProviderHostDecisionAuthority::Policy,
18821            Some(oversized),
18822        ));
18823        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18824    }
18825}
18826
18827#[cfg(test)]
18828mod standalone_capability_platform_tests {
18829    use super::*;
18830
18831    #[test]
18832    fn standalone_workspace_capability_requires_windows_identity_guards() {
18833        let capabilities = baseline_capabilities().unwrap();
18834        let advertised = capabilities.iter().any(|capability| {
18835            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18836        });
18837        assert_eq!(advertised, cfg!(windows));
18838
18839        let request = NodeRequest::CreateStandaloneWorkspace {
18840            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18841            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18842            initial_branch: None,
18843        };
18844        assert_eq!(
18845            request_uses_unnegotiated_capability(&request, &capabilities),
18846            !cfg!(windows),
18847        );
18848    }
18849}
18850
18851
18852#[cfg(test)]
18853mod station_knobs_resolve_tests {
18854    use super::*;
18855    use gate4agent_catalog::EnvMutation;
18856    use gate4agent_runtime_native::{
18857        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18858        NativeLaunchProfileId,
18859    };
18860    use std::ffi::OsString;
18861    use std::sync::Arc;
18862
18863    struct EmptyEnvironmentResolver;
18864
18865    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18866        fn resolve_child_environment(
18867            &self,
18868        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18869            Ok(vec![EnvMutation {
18870                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18871                value: None,
18872            }])
18873        }
18874    }
18875
18876    fn agent(value: &str) -> AgentId {
18877        AgentId::new(value).unwrap()
18878    }
18879
18880    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18881        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18882        let profile_revision =
18883            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18884        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18885            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18886            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18887            provider: agent("claude"),
18888            mode: SessionMode::Pty,
18889            terminal_size: gate4agent_types::TerminalSize {
18890                rows: 24,
18891                columns: 80,
18892            },
18893            prompt: None,
18894            bundle_id: None,
18895            context_id: None,
18896            environment_profile_id: Some(profile_id.clone()),
18897        };
18898        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18899        let catalog = active_registry().unwrap();
18900        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18901        let control = runtime.native_launch_profile_control();
18902        let node_profile = NodeEnvironmentProfile::new(
18903            profile_id.clone(),
18904            profile_revision,
18905            agent("claude"),
18906            [NativeLaunchProfile::new(
18907                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18908                agent("claude"),
18909                TransportKind::Pty,
18910                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18911                Arc::new(EmptyEnvironmentResolver),
18912            )
18913            .unwrap()],
18914        )
18915        .unwrap();
18916        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18917        for native_profile in native_profiles {
18918            runtime.upsert_native_launch_profile(native_profile).unwrap();
18919        }
18920        let workspace = WorkspaceConfig::new(
18921            WorkspaceId::new("primary").unwrap(),
18922            std::env::current_dir().unwrap(),
18923        )
18924        .unwrap();
18925        let shared = NodeShared::new_with_incarnation(
18926            handle,
18927            "fixture-token".to_owned(),
18928            NodeId::new("node-station-knobs-test").unwrap(),
18929            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18930            vec![workspace],
18931            vec![agent("claude")],
18932            ProviderRuntimeStatuses::default(),
18933            None,
18934            Vec::new(),
18935            Vec::new(),
18936            spawn_profiles,
18937            Some(control),
18938            None,
18939            None,
18940            None,
18941            Vec::new(),
18942            Vec::new(),
18943            Vec::new(),
18944            Vec::new(),
18945            None,
18946            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18947        );
18948        shared
18949            .environment_profiles
18950            .write()
18951            .unwrap_or_else(|poisoned| poisoned.into_inner())
18952            .insert(binding.id.clone(), binding);
18953        (shared, spawn_defaults)
18954    }
18955
18956    /// Empty-default catalog refuses unknown network_allowlist ids.
18957    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18958    #[test]
18959    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18960        let (shared, defaults) = station_knobs_fixture();
18961        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18962        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18963        let spec = crate::protocol::SpawnSpec {
18964            target: crate::protocol::SpawnTarget {
18965                node_id: shared.node_id.clone(),
18966                workspace_id: WorkspaceId::new("primary").unwrap(),
18967                worktree_id: None,
18968            },
18969            profile_id: defaults.profile_id.clone(),
18970            expected_profile_revision: defaults.revision.clone(),
18971            overrides: crate::protocol::SpawnOverrides {
18972                network_allowlist: Some(unknown),
18973                browser_profile_id: Some(browser),
18974                ..crate::protocol::SpawnOverrides::default()
18975            },
18976            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18977            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18978                "station-knobs-unknown-allowlist",
18979            )
18980            .unwrap(),
18981            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18982        };
18983        let resolved = spec.resolve(&defaults).unwrap();
18984        let failure = shared
18985            .resolve_environment_profile(&resolved)
18986            .expect_err("unknown allowlist must refuse");
18987        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18988    }
18989
18990    /// Registered network_allowlist (+ browser_profile_id when feature off)
18991    /// echo onto the env-profile receipt. Empty catalog still allows omit
18992    /// (None). Empty/whitespace refuse is type-level. Feature-off
18993    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18994    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18995    #[test]
18996    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18997        let (shared, defaults) = station_knobs_fixture();
18998        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18999        #[cfg(not(feature = "dig2-station-probe"))]
19000        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19001        shared
19002            .network_allowlist_catalog
19003            .write()
19004            .unwrap_or_else(|poisoned| poisoned.into_inner())
19005            .insert_id(allowlist.clone())
19006            .unwrap();
19007        let spec = crate::protocol::SpawnSpec {
19008            target: crate::protocol::SpawnTarget {
19009                node_id: shared.node_id.clone(),
19010                workspace_id: WorkspaceId::new("primary").unwrap(),
19011                worktree_id: None,
19012            },
19013            profile_id: defaults.profile_id.clone(),
19014            expected_profile_revision: defaults.revision.clone(),
19015            overrides: crate::protocol::SpawnOverrides {
19016                network_allowlist: Some(allowlist.clone()),
19017                #[cfg(not(feature = "dig2-station-probe"))]
19018                browser_profile_id: Some(browser.clone()),
19019                ..crate::protocol::SpawnOverrides::default()
19020            },
19021            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19022            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19023                .unwrap(),
19024            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19025        };
19026        let resolved = spec.resolve(&defaults).unwrap();
19027        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19028        #[cfg(not(feature = "dig2-station-probe"))]
19029        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19030        let receipt = shared
19031            .resolve_environment_profile(&resolved)
19032            .expect("resolve ok")
19033            .expect("env profile present");
19034        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19035        #[cfg(not(feature = "dig2-station-probe"))]
19036        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19037        #[cfg(feature = "dig2-station-probe")]
19038        assert_eq!(receipt.browser_profile_id, None);
19039        assert_eq!(receipt.profile_id.as_str(), "local-claude");
19040
19041        let omitted = crate::protocol::SpawnSpec {
19042            overrides: crate::protocol::SpawnOverrides::default(),
19043            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19044                .unwrap(),
19045            ..spec
19046        };
19047        let resolved_omit = omitted.resolve(&defaults).unwrap();
19048        let bare = shared
19049            .resolve_environment_profile(&resolved_omit)
19050            .expect("resolve ok")
19051            .expect("env profile present");
19052        assert_eq!(bare.network_allowlist, None);
19053        assert_eq!(bare.browser_profile_id, None);
19054    }
19055
19056    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
19057    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
19058    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
19059    /// unit-tested separately (probe refuses before lease on Linux).
19060    #[cfg(feature = "dig2-station-probe")]
19061    #[test]
19062    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19063        let (shared, defaults) = station_knobs_fixture();
19064        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19065        let spec = crate::protocol::SpawnSpec {
19066            target: crate::protocol::SpawnTarget {
19067                node_id: shared.node_id.clone(),
19068                workspace_id: WorkspaceId::new("primary").unwrap(),
19069                worktree_id: None,
19070            },
19071            profile_id: defaults.profile_id.clone(),
19072            expected_profile_revision: defaults.revision.clone(),
19073            overrides: crate::protocol::SpawnOverrides {
19074                browser_profile_id: Some(browser),
19075                ..crate::protocol::SpawnOverrides::default()
19076            },
19077            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19078            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19079                "station-knobs-probe-refuse",
19080            )
19081            .unwrap(),
19082            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19083        };
19084        let resolved = spec.resolve(&defaults).unwrap();
19085        let failure = shared
19086            .resolve_environment_profile(&resolved)
19087            .expect_err("browser_profile with failing probe must refuse");
19088        #[cfg(windows)]
19089        {
19090            assert!(
19091                matches!(
19092                    failure.code,
19093                    NodeFailureCode::BrowserStationUnreachable
19094                        | NodeFailureCode::BrowserStationProbeUnavailable
19095                ),
19096                "unexpected code {:?}",
19097                failure.code
19098            );
19099        }
19100        #[cfg(not(windows))]
19101        {
19102            assert_eq!(
19103                failure.code,
19104                NodeFailureCode::BrowserStationProbeUnavailable
19105            );
19106        }
19107    }
19108
19109    /// Feature on: omitting browser_profile_id still resolves (no probe).
19110    #[cfg(feature = "dig2-station-probe")]
19111    #[test]
19112    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19113        let (shared, defaults) = station_knobs_fixture();
19114        let spec = crate::protocol::SpawnSpec {
19115            target: crate::protocol::SpawnTarget {
19116                node_id: shared.node_id.clone(),
19117                workspace_id: WorkspaceId::new("primary").unwrap(),
19118                worktree_id: None,
19119            },
19120            profile_id: defaults.profile_id.clone(),
19121            expected_profile_revision: defaults.revision.clone(),
19122            overrides: crate::protocol::SpawnOverrides::default(),
19123            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19124            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19125                "station-knobs-probe-omit",
19126            )
19127            .unwrap(),
19128            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19129        };
19130        let resolved = spec.resolve(&defaults).unwrap();
19131        let receipt = shared
19132            .resolve_environment_profile(&resolved)
19133            .expect("omit browser_profile must not probe")
19134            .expect("env profile present");
19135        assert_eq!(receipt.browser_profile_id, None);
19136    }
19137
19138    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19139    /// refuses a second holder for the same opaque profile id; release on
19140    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19141    #[cfg(feature = "dig2-station-probe")]
19142    #[test]
19143    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19144        let (shared, _defaults) = station_knobs_fixture();
19145        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19146        let workspace_id = WorkspaceId::new("primary").unwrap();
19147        let address_a = SessionAddress {
19148            workspace_id: workspace_id.clone(),
19149            session: SessionKey {
19150                instance_id: AgentInstanceId(101),
19151                generation: SessionGeneration(1),
19152            },
19153        };
19154        let address_b = SessionAddress {
19155            workspace_id: workspace_id.clone(),
19156            session: SessionKey {
19157                instance_id: AgentInstanceId(102),
19158                generation: SessionGeneration(1),
19159            },
19160        };
19161        let guard_a = crate::dig2_station_lease::acquire_lease(
19162            &shared.browser_station_leases,
19163            profile.clone(),
19164            address_a.session.instance_id,
19165            1,
19166        )
19167        .expect("first lease");
19168        let busy = crate::dig2_station_lease::acquire_lease(
19169            &shared.browser_station_leases,
19170            profile.clone(),
19171            address_b.session.instance_id,
19172            2,
19173        )
19174        .expect_err("second holder must refuse");
19175        assert!(matches!(
19176            busy,
19177            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19178        ));
19179        // Simulate a bound session so remove_binding clears the lease.
19180        shared.bind_session_with_policy(
19181            &address_a,
19182            ProviderRuntimePolicy::raw_pty(),
19183            None,
19184        );
19185        guard_a.retain();
19186        assert!(shared.remove_binding(&address_a).is_some());
19187        crate::dig2_station_lease::acquire_lease(
19188            &shared.browser_station_leases,
19189            profile,
19190            address_b.session.instance_id,
19191            3,
19192        )
19193        .expect("reacquire after clear")
19194        .retain();
19195        assert!(
19196            crate::dig2_station_lease::release_lease_for_holder(
19197                &shared.browser_station_leases,
19198                address_b.session.instance_id,
19199            )
19200        );
19201    }
19202
19203    /// Feature-off: no browser_station_leases field; stub echo documented in
19204    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19205    #[cfg(not(feature = "dig2-station-probe"))]
19206    #[test]
19207    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19208        let (shared, defaults) = station_knobs_fixture();
19209        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19210        let spec = crate::protocol::SpawnSpec {
19211            target: crate::protocol::SpawnTarget {
19212                node_id: shared.node_id.clone(),
19213                workspace_id: WorkspaceId::new("primary").unwrap(),
19214                worktree_id: None,
19215            },
19216            profile_id: defaults.profile_id.clone(),
19217            expected_profile_revision: defaults.revision.clone(),
19218            overrides: crate::protocol::SpawnOverrides {
19219                browser_profile_id: Some(browser.clone()),
19220                ..crate::protocol::SpawnOverrides::default()
19221            },
19222            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19223            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19224                .unwrap(),
19225            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19226        };
19227        let resolved = spec.resolve(&defaults).unwrap();
19228        let receipt = shared
19229            .resolve_environment_profile(&resolved)
19230            .expect("stub resolve")
19231            .expect("env profile");
19232        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19233    }
19234
19235
19236    /// Startup file loader registers ids into the empty-default catalog.
19237    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19238    #[test]
19239    fn network_allowlist_catalog_loads_from_temp_file() {
19240        let unique = std::time::SystemTime::now()
19241            .duration_since(std::time::UNIX_EPOCH)
19242            .unwrap()
19243            .as_nanos();
19244        let path = std::env::temp_dir().join(format!(
19245            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19246            std::process::id(),
19247        ));
19248        std::fs::write(
19249            &path,
19250            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19251        )
19252        .unwrap();
19253        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19254            .expect("temp catalog file must load");
19255        let _ = std::fs::remove_file(&path);
19256        assert_eq!(loaded.len(), 2);
19257        let (shared, defaults) = station_knobs_fixture();
19258        {
19259            let mut catalog = shared
19260                .network_allowlist_catalog
19261                .write()
19262                .unwrap_or_else(|poisoned| poisoned.into_inner());
19263            *catalog = loaded;
19264        }
19265        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19266        let spec = crate::protocol::SpawnSpec {
19267            target: crate::protocol::SpawnTarget {
19268                node_id: shared.node_id.clone(),
19269                workspace_id: WorkspaceId::new("primary").unwrap(),
19270                worktree_id: None,
19271            },
19272            profile_id: defaults.profile_id.clone(),
19273            expected_profile_revision: defaults.revision.clone(),
19274            overrides: crate::protocol::SpawnOverrides {
19275                network_allowlist: Some(allowlist.clone()),
19276                ..crate::protocol::SpawnOverrides::default()
19277            },
19278            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19279            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19280                "station-knobs-catalog-file",
19281            )
19282            .unwrap(),
19283            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19284        };
19285        let resolved = spec.resolve(&defaults).unwrap();
19286        let receipt = shared
19287            .resolve_environment_profile(&resolved)
19288            .expect("registered via file load")
19289            .expect("env profile present");
19290        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19291    }
19292
19293    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19294    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19295    #[test]
19296    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19297        let (shared, _defaults) = station_knobs_fixture();
19298        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19299        shared
19300            .network_allowlist_catalog
19301            .write()
19302            .unwrap_or_else(|poisoned| poisoned.into_inner())
19303            .insert_id(allowlist.clone())
19304            .unwrap();
19305        let snapshot = shared.snapshot();
19306        let inventory = snapshot
19307            .launch_inventory
19308            .expect("launch inventory present");
19309        let ids = inventory
19310            .network_allowlists
19311            .expect("non-empty catalog surfaces ids");
19312        assert_eq!(ids, vec![allowlist]);
19313    }
19314
19315    /// Track B: registered id with provider_native codex_network_access refuses
19316    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19317    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19318    #[test]
19319    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19320        let (shared, defaults) = station_knobs_fixture();
19321        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19322        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19323            id: allowlist.clone(),
19324            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19325                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19326                peer: "127.0.0.1:443".to_owned(),
19327            }],
19328            provider_native: Some(
19329                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19330                    codex_network_access: Some(true),
19331                },
19332            ),
19333        };
19334        shared
19335            .network_allowlist_catalog
19336            .write()
19337            .unwrap_or_else(|poisoned| poisoned.into_inner())
19338            .insert(entry)
19339            .unwrap();
19340        let spec = crate::protocol::SpawnSpec {
19341            target: crate::protocol::SpawnTarget {
19342                node_id: shared.node_id.clone(),
19343                workspace_id: WorkspaceId::new("primary").unwrap(),
19344                worktree_id: None,
19345            },
19346            profile_id: defaults.profile_id.clone(),
19347            expected_profile_revision: defaults.revision.clone(),
19348            overrides: crate::protocol::SpawnOverrides {
19349                network_allowlist: Some(allowlist),
19350                ..crate::protocol::SpawnOverrides::default()
19351            },
19352            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19353            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19354                "station-knobs-unsupported-mapping",
19355            )
19356            .unwrap(),
19357            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19358        };
19359        let resolved = spec.resolve(&defaults).unwrap();
19360        assert_eq!(resolved.provider.as_str(), "claude");
19361        let failure = shared
19362            .resolve_environment_profile(&resolved)
19363            .expect_err("claude + codex_network_access must refuse");
19364        assert_eq!(
19365            failure.code,
19366            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19367        );
19368    }
19369
19370    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19371    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19372    /// Never secrets on C2.
19373    #[test]
19374    fn resolve_codex_network_access_overlay_moderate_only() {
19375        let (shared, defaults) = station_knobs_fixture();
19376        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19377        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19378            id: allowlist.clone(),
19379            permits: Vec::new(),
19380            provider_native: Some(
19381                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19382                    codex_network_access: Some(true),
19383                },
19384            ),
19385        };
19386        shared
19387            .network_allowlist_catalog
19388            .write()
19389            .unwrap_or_else(|poisoned| poisoned.into_inner())
19390            .insert(entry)
19391            .unwrap();
19392
19393        let overlay = shared
19394            .resolve_codex_network_access_overlay_args(
19395                &agent("codex"),
19396                SessionMode::Pty,
19397                ApprovalLevel::Moderate,
19398                Some(&allowlist),
19399            )
19400            .expect("Moderate Codex maps network_access");
19401        assert_eq!(
19402            overlay,
19403            vec![
19404                std::ffi::OsString::from("-c"),
19405                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19406            ]
19407        );
19408
19409        let acp = shared
19410            .resolve_codex_network_access_overlay_args(
19411                &agent("codex"),
19412                SessionMode::Acp,
19413                ApprovalLevel::Moderate,
19414                Some(&allowlist),
19415            )
19416            .unwrap();
19417        assert_eq!(acp, overlay);
19418
19419        let inline = shared
19420            .resolve_codex_network_access_overlay_args(
19421                &agent("codex"),
19422                SessionMode::Inline,
19423                ApprovalLevel::Moderate,
19424                Some(&allowlist),
19425            )
19426            .unwrap_err();
19427        assert_eq!(
19428            inline.code,
19429            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19430        );
19431
19432        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19433            let spec = crate::protocol::SpawnSpec {
19434                target: crate::protocol::SpawnTarget {
19435                    node_id: shared.node_id.clone(),
19436                    workspace_id: WorkspaceId::new("primary").unwrap(),
19437                    worktree_id: None,
19438                },
19439                profile_id: defaults.profile_id.clone(),
19440                expected_profile_revision: defaults.revision.clone(),
19441                overrides: crate::protocol::SpawnOverrides {
19442                    provider: crate::protocol::SpawnOverride::Set {
19443                        value: agent("codex"),
19444                    },
19445                    approval_level: Some(level),
19446                    network_allowlist: Some(allowlist.clone()),
19447                    ..crate::protocol::SpawnOverrides::default()
19448                },
19449                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19450                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19451                    "codex-net-refuse-{}",
19452                    match level {
19453                        ApprovalLevel::ReadOnly => "ro",
19454                        ApprovalLevel::FullAuto => "fa",
19455                        _ => "other",
19456                    }
19457                ))
19458                .unwrap(),
19459                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19460            };
19461            let resolved = spec.resolve(&defaults).unwrap();
19462            let failure = shared
19463                .resolve_environment_profile(&resolved)
19464                .expect_err("RO/FullAuto must refuse");
19465            assert_eq!(
19466                failure.code,
19467                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19468            );
19469        }
19470    }
19471
19472    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19473    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19474    /// Never secrets on C2; does not invent provider_native Claude keys.
19475    #[test]
19476    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19477        let (shared, _defaults) = station_knobs_fixture();
19478        let allowlist =
19479            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19480        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19481            id: allowlist.clone(),
19482            permits: vec![
19483                crate::network_allowlist_catalog::NetworkPermitSketch {
19484                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19485                    peer: "github.com:443".to_owned(),
19486                },
19487                crate::network_allowlist_catalog::NetworkPermitSketch {
19488                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19489                    peer: "*.npmjs.org".to_owned(),
19490                },
19491            ],
19492            provider_native: None,
19493        };
19494        shared
19495            .network_allowlist_catalog
19496            .write()
19497            .unwrap_or_else(|poisoned| poisoned.into_inner())
19498            .insert(entry)
19499            .unwrap();
19500
19501        let unique = std::time::SystemTime::now()
19502            .duration_since(std::time::UNIX_EPOCH)
19503            .unwrap()
19504            .as_nanos();
19505        let provider_home = std::env::temp_dir().join(format!(
19506            "gate4agent-claude-provider-home-{}-{unique}",
19507            std::process::id(),
19508        ));
19509        std::fs::create_dir_all(&provider_home).unwrap();
19510
19511        let overlay = shared
19512            .resolve_network_allowlist_overlay_args(
19513                &agent("claude"),
19514                SessionMode::Pty,
19515                ApprovalLevel::Moderate,
19516                Some(&allowlist),
19517                Some(provider_home.as_path()),
19518            )
19519            .expect("Claude PTY settings overlay on unix");
19520        let settings_path = provider_home.join(
19521            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19522        );
19523        assert_eq!(
19524            overlay,
19525            vec![
19526                std::ffi::OsString::from("--settings"),
19527                std::ffi::OsString::from(settings_path.as_os_str()),
19528            ]
19529        );
19530        let body = std::fs::read_to_string(&settings_path).unwrap();
19531        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19532        assert_eq!(value["sandbox"]["enabled"], true);
19533        assert_eq!(
19534            value["sandbox"]["network"]["allowedDomains"],
19535            serde_json::json!(["github.com", "*.npmjs.org"])
19536        );
19537        assert!(settings_path.starts_with(&provider_home));
19538
19539        let acp = shared
19540            .resolve_network_allowlist_overlay_args(
19541                &agent("claude"),
19542                SessionMode::Acp,
19543                ApprovalLevel::Moderate,
19544                Some(&allowlist),
19545                Some(provider_home.as_path()),
19546            )
19547            .expect_err("Claude ACP must refuse settings overlay");
19548        assert_eq!(
19549            acp.code,
19550            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19551        );
19552
19553        let no_home = shared
19554            .resolve_network_allowlist_overlay_args(
19555                &agent("claude"),
19556                SessionMode::Pty,
19557                ApprovalLevel::Moderate,
19558                Some(&allowlist),
19559                None,
19560            )
19561            .expect_err("Claude overlay requires ProviderHome");
19562        assert_eq!(
19563            no_home.code,
19564            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19565        );
19566
19567        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19568        shared
19569            .network_allowlist_catalog
19570            .write()
19571            .unwrap_or_else(|poisoned| poisoned.into_inner())
19572            .insert_id(bare.clone())
19573            .unwrap();
19574        let empty = shared
19575            .resolve_network_allowlist_overlay_args(
19576                &agent("claude"),
19577                SessionMode::Pty,
19578                ApprovalLevel::Moderate,
19579                Some(&bare),
19580                Some(provider_home.as_path()),
19581            )
19582            .unwrap();
19583        assert!(empty.is_empty());
19584
19585        let _ = std::fs::remove_dir_all(&provider_home);
19586    }
19587
19588    /// Kimi / Grok with permits stay without invented argv overlays.
19589    #[test]
19590    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19591        let (shared, _defaults) = station_knobs_fixture();
19592        let allowlist =
19593            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19594        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19595            id: allowlist.clone(),
19596            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19597                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19598                peer: "example.com:443".to_owned(),
19599            }],
19600            provider_native: None,
19601        };
19602        shared
19603            .network_allowlist_catalog
19604            .write()
19605            .unwrap_or_else(|poisoned| poisoned.into_inner())
19606            .insert(entry)
19607            .unwrap();
19608        for provider in ["kimi", "grok"] {
19609            let overlay = shared
19610                .resolve_network_allowlist_overlay_args(
19611                    &agent(provider),
19612                    SessionMode::Pty,
19613                    ApprovalLevel::Moderate,
19614                    Some(&allowlist),
19615                    None,
19616                )
19617                .unwrap();
19618            assert!(
19619                overlay.is_empty(),
19620                "{provider} must not invent network argv from permits"
19621            );
19622        }
19623    }
19624
19625    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19626    #[test]
19627    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19628        let unique = std::time::SystemTime::now()
19629            .duration_since(std::time::UNIX_EPOCH)
19630            .unwrap()
19631            .as_nanos();
19632        let path = std::env::temp_dir().join(format!(
19633            "gate4agent-node-allowlist-json-{}-{unique}.json",
19634            std::process::id(),
19635        ));
19636        std::fs::write(
19637            &path,
19638            r#"{
19639              "schema_version": 2,
19640              "entries": [
19641                {
19642                  "id": "egress-default",
19643                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19644                },
19645                { "id": "lab-egress" }
19646              ]
19647            }"#,
19648        )
19649        .unwrap();
19650        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19651            .expect("json v2 catalog must load");
19652        let _ = std::fs::remove_file(&path);
19653        assert_eq!(loaded.len(), 2);
19654        let entry = loaded
19655            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19656            .unwrap();
19657        assert_eq!(entry.permits.len(), 1);
19658        let (shared, defaults) = station_knobs_fixture();
19659        {
19660            let mut catalog = shared
19661                .network_allowlist_catalog
19662                .write()
19663                .unwrap_or_else(|poisoned| poisoned.into_inner());
19664            *catalog = loaded;
19665        }
19666        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19667        let spec = crate::protocol::SpawnSpec {
19668            target: crate::protocol::SpawnTarget {
19669                node_id: shared.node_id.clone(),
19670                workspace_id: WorkspaceId::new("primary").unwrap(),
19671                worktree_id: None,
19672            },
19673            profile_id: defaults.profile_id.clone(),
19674            expected_profile_revision: defaults.revision.clone(),
19675            overrides: crate::protocol::SpawnOverrides {
19676                network_allowlist: Some(allowlist.clone()),
19677                ..crate::protocol::SpawnOverrides::default()
19678            },
19679            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19680            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19681                "station-knobs-catalog-json",
19682            )
19683            .unwrap(),
19684            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19685        };
19686        let resolved = spec.resolve(&defaults).unwrap();
19687        let receipt = shared
19688            .resolve_environment_profile(&resolved)
19689            .expect("permits-only entry ok for claude")
19690            .expect("env profile present");
19691        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19692        let inventory = shared
19693            .snapshot()
19694            .launch_inventory
19695            .expect("launch inventory present");
19696        let ids = inventory.network_allowlists.expect("ids surface");
19697        assert_eq!(
19698            ids,
19699            vec![
19700                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19701                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19702            ]
19703        );
19704    }
19705
19706
19707}
19708
19709
19710#[cfg(test)]
19711mod workspace_environment_collision_tests {
19712    use super::*;
19713    use crate::session_environment::{
19714        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19715    };
19716    use gate4agent_catalog::EnvMutation;
19717    use gate4agent_runtime_native::{
19718        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19719        NativeLaunchProfileId,
19720    };
19721    use std::ffi::OsString;
19722    use std::sync::Arc;
19723
19724    struct EmptyEnvironmentResolver;
19725
19726    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19727        fn resolve_child_environment(
19728            &self,
19729        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19730            Ok(vec![EnvMutation {
19731                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19732                value: None,
19733            }])
19734        }
19735    }
19736
19737    fn agent(value: &str) -> AgentId {
19738        AgentId::new(value).unwrap()
19739    }
19740
19741    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19742    fn claude_config_dir_collision_fixture() -> (
19743        NodeShared,
19744        ResolvedEnvironmentProfileReceipt,
19745        ResolvedEnvironmentProfileReceipt,
19746    ) {
19747        let catalog = active_registry().unwrap();
19748        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19749        let control = runtime.native_launch_profile_control();
19750        let workspace = WorkspaceConfig::new(
19751            WorkspaceId::new("primary").unwrap(),
19752            std::env::current_dir().unwrap(),
19753        )
19754        .unwrap();
19755        let shared = NodeShared::new_with_incarnation(
19756            handle,
19757            "fixture-token".to_owned(),
19758            NodeId::new("node-env-collision-test").unwrap(),
19759            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19760            vec![workspace],
19761            vec![agent("claude")],
19762            ProviderRuntimeStatuses::default(),
19763            None,
19764            Vec::new(),
19765            Vec::new(),
19766            SpawnProfileRegistry::default(),
19767            Some(control),
19768            None,
19769            None,
19770            None,
19771            Vec::new(),
19772            Vec::new(),
19773            Vec::new(),
19774            Vec::new(),
19775            None,
19776            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19777        );
19778
19779        let mut receipts = Vec::new();
19780        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19781            let profile_id =
19782                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19783            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19784                "claude-config-dir-{suffix}-r1"
19785            ))
19786            .unwrap();
19787            let materialization = NodeSessionMaterializationProfile::new(
19788                Vec::new(),
19789                vec![NodeSessionPathBinding::new(
19790                    "CLAUDE_CONFIG_DIR",
19791                    NodeSessionPathClass::ProviderHome,
19792                )
19793                .unwrap()],
19794                Vec::new(),
19795            )
19796            .unwrap();
19797            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19798                profile_id.clone(),
19799                profile_revision.clone(),
19800                agent("claude"),
19801                [NativeLaunchProfile::new(
19802                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19803                        .unwrap(),
19804                    agent("claude"),
19805                    TransportKind::Pty,
19806                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19807                    Arc::new(EmptyEnvironmentResolver),
19808                )
19809                .unwrap()],
19810                Some(materialization),
19811            )
19812            .unwrap();
19813            let (binding, native_profiles, materialization) = node_profile.into_parts();
19814            for native_profile in native_profiles {
19815                runtime.upsert_native_launch_profile(native_profile).unwrap();
19816            }
19817            shared
19818                .environment_profiles
19819                .write()
19820                .unwrap_or_else(|poisoned| poisoned.into_inner())
19821                .insert(binding.id.clone(), binding);
19822            shared
19823                .environment_materialization_profiles
19824                .write()
19825                .unwrap_or_else(|poisoned| poisoned.into_inner())
19826                .insert(profile_id.clone(), materialization.unwrap());
19827            receipts.push(ResolvedEnvironmentProfileReceipt {
19828                profile_id,
19829                profile_revision,
19830                network_allowlist: None,
19831                browser_profile_id: None,
19832            });
19833        }
19834        let profile_b = receipts.pop().unwrap();
19835        let profile_a = receipts.pop().unwrap();
19836        (shared, profile_a, profile_b)
19837    }
19838
19839    #[test]
19840    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19841        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19842        let primary = WorkspaceId::new("primary").unwrap();
19843        let worktree = WorkspaceId::new("worktree-b").unwrap();
19844        let address = SessionAddress {
19845            workspace_id: primary.clone(),
19846            session: SessionKey {
19847                instance_id: AgentInstanceId(41),
19848                generation: SessionGeneration(1),
19849            },
19850        };
19851        shared.bind_session_with_policy(
19852            &address,
19853            ProviderRuntimePolicy::raw_pty(),
19854            Some(profile_a.clone()),
19855        );
19856
19857        let refused = shared
19858            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19859            .unwrap_err();
19860        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19861
19862        // Some vs None on the same cwd is also a collision.
19863        let refused_none = shared
19864            .ensure_workspace_environment_profile_compatible(&primary, None)
19865            .unwrap_err();
19866        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19867
19868        // Same profile on the same workspace remains allowed.
19869        shared
19870            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19871            .unwrap();
19872
19873        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19874        shared
19875            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19876            .unwrap();
19877    }
19878
19879    #[tokio::test]
19880    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19881        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19882        let primary = WorkspaceId::new("primary").unwrap();
19883        let address = SessionAddress {
19884            workspace_id: primary.clone(),
19885            session: SessionKey {
19886                instance_id: AgentInstanceId(41),
19887                generation: SessionGeneration(1),
19888            },
19889        };
19890        shared.bind_session_with_policy(
19891            &address,
19892            ProviderRuntimePolicy::raw_pty(),
19893            Some(profile_a),
19894        );
19895
19896        let failure = shared
19897            .spawn_session_with_deadline(
19898                primary,
19899                agent("claude"),
19900                SessionMode::Pty,
19901                gate4agent_types::TerminalSize {
19902                    rows: 24,
19903                    columns: 80,
19904                },
19905                None,
19906                Some(profile_b),
19907                None,
19908                None,
19909                None,
19910                None,
19911                SpawnRecordPolicy::ProviderIdentityOnly,
19912                Some(Instant::now() + Duration::from_secs(30)),
19913                &[],
19914                None,
19915                ApprovalLevel::default(),
19916                None,
19917                None,
19918            )
19919            .await
19920            .unwrap_err();
19921        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19922        // Guard runs before instance id mint / bind.
19923        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19924        assert!(shared.handle.snapshot().sessions.is_empty());
19925    }
19926}
19927
19928#[cfg(test)]
19929mod spawn_characterization_tests {
19930    use super::*;
19931    use crate::session_environment::{
19932        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19933        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19934        NodeSessionPathClass,
19935    };
19936    use gate4agent_catalog::EnvMutation;
19937    use gate4agent_runtime_native::{
19938        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19939        NativeLaunchProfile, NativeLaunchProfileId,
19940    };
19941    use std::ffi::OsString;
19942    use std::sync::Arc;
19943
19944    struct EmptyEnvironmentResolver;
19945
19946    struct FixtureSecretResolver {
19947        deny: Arc<AtomicBool>,
19948    }
19949
19950    impl NodeSecretResolver for FixtureSecretResolver {
19951        fn resolve(
19952            &self,
19953            _reference: &NodeSecretReference,
19954        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19955            if self.deny.load(Ordering::Acquire) {
19956                Err(NodeSecretResolveError::Denied)
19957            } else {
19958                NodeSecretValue::text("fixture-secret")
19959                    .map_err(|_| NodeSecretResolveError::Unavailable)
19960            }
19961        }
19962    }
19963
19964    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19965        fn resolve_child_environment(
19966            &self,
19967        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19968            Ok(vec![EnvMutation {
19969                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19970                value: None,
19971            }])
19972        }
19973    }
19974
19975    fn agent(value: &str) -> AgentId {
19976        AgentId::new(value).unwrap()
19977    }
19978
19979    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19980        let unique = std::time::SystemTime::now()
19981            .duration_since(std::time::UNIX_EPOCH)
19982            .unwrap()
19983            .as_nanos();
19984        std::env::temp_dir().join(format!(
19985            "gate4agent-node-{label}-{}-{unique}",
19986            std::process::id(),
19987        ))
19988    }
19989
19990    fn materialization_test_shared(
19991        deny_secret: bool,
19992    ) -> (
19993        NodeShared,
19994        NativeRuntime,
19995        ResolvedEnvironmentProfileReceipt,
19996        PathBuf,
19997        Arc<AtomicBool>,
19998    ) {
19999        let workspace = WorkspaceConfig::new(
20000            WorkspaceId::new("primary").unwrap(),
20001            std::env::current_dir().unwrap(),
20002        )
20003        .unwrap();
20004        materialization_test_shared_with_workspace(deny_secret, workspace)
20005    }
20006
20007    fn materialization_test_shared_with_workspace(
20008        deny_secret: bool,
20009        workspace: WorkspaceConfig,
20010    ) -> (
20011        NodeShared,
20012        NativeRuntime,
20013        ResolvedEnvironmentProfileReceipt,
20014        PathBuf,
20015        Arc<AtomicBool>,
20016    ) {
20017        let root = temporary_workspace_root(if deny_secret {
20018            "materialization-denied"
20019        } else {
20020            "materialization-ready"
20021        });
20022        std::fs::create_dir_all(&root).unwrap();
20023        let materialization_root = root.join("session-environments");
20024        let deny = Arc::new(AtomicBool::new(deny_secret));
20025        let resolver = Arc::new(FixtureSecretResolver {
20026            deny: Arc::clone(&deny),
20027        });
20028        let materializer = SessionEnvironmentMaterializer::new(
20029            materialization_root,
20030            resolver,
20031        )
20032        .unwrap();
20033        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20034        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20035            "materialized-claude-r1",
20036        )
20037        .unwrap();
20038        let materialization = NodeSessionMaterializationProfile::new(
20039            vec![NodeSessionEnvironmentMutation::SetSecret {
20040                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20041                reference: NodeSecretReference::new("fixture-token").unwrap(),
20042            }],
20043            vec![NodeSessionPathBinding::new(
20044                "GATE4AGENT_PROVIDER_HOME",
20045                NodeSessionPathClass::ProviderHome,
20046            )
20047            .unwrap()],
20048            vec![NodeSessionFile::secret(
20049                NodeSessionPathClass::Config,
20050                "auth/token",
20051                NodeSecretReference::new("fixture-token").unwrap(),
20052            )
20053            .unwrap()],
20054        )
20055        .unwrap();
20056        let catalog = active_registry().unwrap();
20057        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20058        let control = runtime.native_launch_profile_control();
20059        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20060            profile_id.clone(),
20061            profile_revision.clone(),
20062            agent("claude"),
20063            [NativeLaunchProfile::new(
20064                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20065                agent("claude"),
20066                TransportKind::Pty,
20067                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20068                Arc::new(EmptyEnvironmentResolver),
20069            )
20070            .unwrap()],
20071            Some(materialization),
20072        )
20073        .unwrap();
20074        let (binding, native_profiles, materialization) = node_profile.into_parts();
20075        for native_profile in native_profiles {
20076            runtime.upsert_native_launch_profile(native_profile).unwrap();
20077        }
20078        let shared = NodeShared::new_with_incarnation(
20079            handle,
20080            "fixture-token".to_owned(),
20081            NodeId::new("node-materialization-test").unwrap(),
20082            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20083            vec![workspace],
20084            vec![agent("claude")],
20085            ProviderRuntimeStatuses::default(),
20086            None,
20087            Vec::new(),
20088            Vec::new(),
20089            SpawnProfileRegistry::default(),
20090            Some(control),
20091            None,
20092            Some(materializer),
20093            None,
20094            Vec::new(),
20095            Vec::new(),
20096            Vec::new(),
20097            Vec::new(),
20098            None,
20099            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20100        );
20101        shared
20102            .environment_profiles
20103            .write()
20104            .unwrap_or_else(|poisoned| poisoned.into_inner())
20105            .insert(binding.id.clone(), binding);
20106        shared
20107            .environment_materialization_profiles
20108            .write()
20109            .unwrap_or_else(|poisoned| poisoned.into_inner())
20110            .insert(profile_id.clone(), materialization.unwrap());
20111        (
20112            shared,
20113            runtime,
20114            ResolvedEnvironmentProfileReceipt {
20115                profile_id,
20116                profile_revision,
20117                network_allowlist: None,
20118                browser_profile_id: None,
20119            },
20120            root,
20121            deny,
20122        )
20123    }
20124
20125
20126    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20127
20128    async fn characterization_spawn(
20129        shared: &NodeShared,
20130        mode: SessionMode,
20131        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20132        required: &[ProviderRuntimeCapability],
20133        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20134    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20135        shared
20136            .spawn_session_with_deadline(
20137                WorkspaceId::new("primary").unwrap(),
20138                agent("claude"),
20139                mode,
20140                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20141                None,
20142                environment_profile,
20143                None,
20144                None,
20145                None,
20146                Some(ProviderRuntimePolicy::raw_pty()),
20147                SpawnRecordPolicy::ProviderIdentityOnly,
20148                Some(Instant::now() + Duration::from_secs(30)),
20149                required,
20150                harness_mcp,
20151                ApprovalLevel::default(),
20152                None,
20153                None,
20154            )
20155            .await
20156    }
20157
20158    fn assert_no_session_state(shared: &NodeShared) {
20159        assert!(shared.handle.snapshot().sessions.is_empty());
20160        assert!(shared
20161            .session_bindings
20162            .lock()
20163            .unwrap_or_else(|poisoned| poisoned.into_inner())
20164            .is_empty());
20165        assert!(shared
20166            .materializations
20167            .lock()
20168            .unwrap_or_else(|poisoned| poisoned.into_inner())
20169            .is_empty());
20170    }
20171
20172    #[tokio::test]
20173    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20174        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20175        let error = characterization_spawn(
20176            &shared,
20177            SessionMode::Pty,
20178            None,
20179            &[ProviderRuntimeCapability::SemanticReadiness],
20180            None,
20181        )
20182        .await
20183        .unwrap_err();
20184        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20185        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20186        assert_no_session_state(&shared);
20187        drop(shared);
20188        drop(runtime);
20189        std::fs::remove_dir_all(root).unwrap();
20190    }
20191
20192    #[tokio::test]
20193    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20194        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20195        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20196            .await
20197            .unwrap_err();
20198        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20199        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20200        assert_no_session_state(&shared);
20201        drop(shared);
20202        drop(runtime);
20203        std::fs::remove_dir_all(root).unwrap();
20204    }
20205
20206    #[test]
20207    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20208        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20209        let address = SessionAddress {
20210            workspace_id: WorkspaceId::new("primary").unwrap(),
20211            session: SessionKey {
20212                instance_id: AgentInstanceId(77),
20213                generation: SessionGeneration(1),
20214            },
20215        };
20216        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20217        let removed = shared.remove_binding(&address).expect("binding removed");
20218        assert!(removed.environment_profile.is_some());
20219        assert!(shared.remove_binding(&address).is_none());
20220        assert_no_session_state(&shared);
20221        drop(shared);
20222        drop(runtime);
20223        std::fs::remove_dir_all(root).unwrap();
20224    }
20225
20226    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20227        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20228        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20229        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20230            .expect("review current test executable as fixture helper");
20231        let registry = HarnessMcpProxyRegistry::new(helper);
20232        let spec = crate::protocol::SpawnSpec {
20233            target: crate::protocol::SpawnTarget {
20234                node_id: NodeId::new("node-characterization").unwrap(),
20235                workspace_id: WorkspaceId::new("primary").unwrap(),
20236                worktree_id: None,
20237            },
20238            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20239            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20240            overrides: crate::protocol::SpawnOverrides::default(),
20241            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20242            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20243            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20244        };
20245        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20246        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20247        let launch = HarnessMcpLaunchV1 {
20248            server_name: "fixture-harness".to_owned(),
20249            args: vec!["--session-proxy".to_owned()],
20250            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20251            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20252            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20253            trace: None,
20254            scrub_env: Vec::new(),
20255        };
20256        let expires = unix_time_ms().saturating_add(30_000);
20257        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20258        registry
20259            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20260            .unwrap()
20261    }
20262
20263    #[tokio::test]
20264    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20265        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20266        let prepared = characterization_prepared_harness("claude").await;
20267        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20268            .await
20269            .unwrap_err();
20270        // Inline is stopped by runtime admission before the harness check runs.
20271        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20272        let mismatched = characterization_prepared_harness("codex").await;
20273        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20274            .await
20275            .unwrap_err();
20276        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20277        assert_no_session_state(&shared);
20278        drop(shared);
20279        drop(runtime);
20280        std::fs::remove_dir_all(root).unwrap();
20281    }
20282
20283    #[tokio::test]
20284    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20285        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20286        shared.native_launch_profile_control = None;
20287        let prepared = characterization_prepared_harness("claude").await;
20288        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20289            .await
20290            .unwrap_err();
20291        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20292        assert_no_session_state(&shared);
20293        drop(shared);
20294        drop(runtime);
20295        std::fs::remove_dir_all(root).unwrap();
20296    }
20297}
20298
20299#[cfg(all(test, windows))]
20300mod tests {
20301    use super::*;
20302    use gate4agent_types::ProviderActivity;
20303    use crate::session_environment::{
20304        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20305        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20306        NodeSessionPathClass,
20307    };
20308    use gate4agent_catalog::EnvMutation;
20309    use gate4agent_runtime_native::{
20310        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20311        NativeLaunchProfile, NativeLaunchProfileId,
20312    };
20313    use std::ffi::OsString;
20314    use std::sync::Arc;
20315
20316    struct EmptyEnvironmentResolver;
20317
20318    struct FixtureSecretResolver {
20319        deny: Arc<AtomicBool>,
20320    }
20321
20322    impl NodeSecretResolver for FixtureSecretResolver {
20323        fn resolve(
20324            &self,
20325            _reference: &NodeSecretReference,
20326        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20327            if self.deny.load(Ordering::Acquire) {
20328                Err(NodeSecretResolveError::Denied)
20329            } else {
20330                NodeSecretValue::text("fixture-secret")
20331                    .map_err(|_| NodeSecretResolveError::Unavailable)
20332            }
20333        }
20334    }
20335
20336    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20337        fn resolve_child_environment(
20338            &self,
20339        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20340            Ok(vec![EnvMutation {
20341                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20342                value: None,
20343            }])
20344        }
20345    }
20346
20347    fn agent(value: &str) -> AgentId {
20348        AgentId::new(value).unwrap()
20349    }
20350
20351    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20352        let catalog = active_registry().unwrap();
20353        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20354        let workspace = WorkspaceConfig::new(
20355            WorkspaceId::new("primary").unwrap(),
20356            std::env::current_dir().unwrap(),
20357        )
20358        .unwrap()
20359        .with_worktree_service_mode(mode);
20360        NodeShared::new(
20361            handle,
20362            "fixture-token".to_owned(),
20363            NodeId::new("node-terminal-test").unwrap(),
20364            vec![workspace],
20365            vec![agent("claude")],
20366        )
20367    }
20368
20369    fn terminal_test_shared() -> NodeShared {
20370        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20371    }
20372
20373    #[cfg(feature = "fixture")]
20374    #[test]
20375    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20376        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20377        assert_eq!(probe.latest_code(), None);
20378
20379        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20380        assert_eq!(
20381            probe.latest_code(),
20382            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20383        );
20384
20385        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20386        assert_eq!(
20387            probe.latest_code(),
20388            Some(NodeFailureCode::SpawnDeadlineExceeded),
20389        );
20390
20391        probe.record(None);
20392        assert_eq!(probe.latest_code(), None);
20393    }
20394
20395    #[test]
20396    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20397        let source = temporary_workspace_root("launch-inventory-source");
20398        let allocation = temporary_workspace_root("launch-inventory-allocation");
20399        std::fs::create_dir_all(&source).unwrap();
20400        std::fs::create_dir_all(&allocation).unwrap();
20401        let workspace_id = WorkspaceId::new("primary").unwrap();
20402        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20403            .unwrap()
20404            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20405        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20406            workspace = workspace
20407                .with_managed_worktree_profile(
20408                    ManagedWorktreeProfile::new(
20409                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20410                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20411                        &allocation,
20412                        "gate4agent",
20413                        "HEAD",
20414                        ManagedWorktreeRetention::RemoveWhenReleased,
20415                    )
20416                    .unwrap(),
20417                )
20418                .unwrap();
20419        }
20420        let overflow = workspace.clone().with_managed_worktree_profile(
20421            ManagedWorktreeProfile::new(
20422                WorktreeProfileId::new("overflow").unwrap(),
20423                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20424                &allocation,
20425                "gate4agent",
20426                "HEAD",
20427                ManagedWorktreeRetention::Retain,
20428            )
20429            .unwrap(),
20430        );
20431        assert!(matches!(
20432            overflow,
20433            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20434                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20435                ..
20436            }),
20437        ));
20438
20439        let catalog = active_registry().unwrap();
20440        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20441        let shared = NodeShared::new(
20442            handle,
20443            "fixture-token".to_owned(),
20444            NodeId::new("node-launch-inventory").unwrap(),
20445            vec![workspace],
20446            vec![agent("claude")],
20447        );
20448        let snapshot = shared.snapshot();
20449        let launch = snapshot.launch_inventory.as_ref().unwrap();
20450        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20451        assert!(launch.bundles.as_ref().unwrap().is_empty());
20452        let profiles = snapshot.workspaces[0]
20453            .managed_worktree_profiles
20454            .as_ref()
20455            .unwrap();
20456        assert_eq!(
20457            snapshot.workspaces[0].worktree_service_mode,
20458            Some(WorktreeServiceMode::Managed),
20459        );
20460        assert_eq!(
20461            profiles.profiles.len(),
20462            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20463        );
20464        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20465        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20466            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20467        }
20468        let legacy = snapshot_for_wire(
20469            &shared,
20470            true,
20471            true,
20472            false,
20473            false,
20474            true,
20475            false,
20476            true,
20477            false,
20478            true,
20479        );
20480        assert!(legacy.launch_inventory.is_none());
20481        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20482
20483        drop(shared);
20484        std::fs::remove_dir_all(source).unwrap();
20485        std::fs::remove_dir_all(allocation).unwrap();
20486    }
20487
20488    #[test]
20489    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20490        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20491            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20492        }));
20493        let source = gate4agent_types::ProviderSource {
20494            family: AdapterFamily::PtySemantic,
20495            binding: AdapterBinding::new(
20496                gate4agent_types::AdapterId::new("claude").unwrap(),
20497                "fixture/v1",
20498                gate4agent_types::AdapterVerification::SyntheticFixture,
20499            )
20500            .unwrap(),
20501        };
20502        let provider = ProviderSnapshot {
20503            sequence: 17,
20504            session: Some(ProviderSessionIdentity {
20505                key: ProviderSessionKey::SessionId,
20506                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20507                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20508            }),
20509            model: Some("PRIVATE_MODEL".to_owned()),
20510            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20511            completed_turns: 3,
20512            usage: gate4agent_types::TokenUsage {
20513                input_tokens: 10,
20514                output_tokens: 20,
20515                cache_read_tokens: 30,
20516                cache_write_tokens: 40,
20517                reasoning_tokens: 50,
20518                context_window: Some(200_000),
20519            },
20520            lead_activity: ProviderActivity::WaitingForInput,
20521            activity: ProviderActivity::WaitingForInput,
20522            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20523            active_tools: (0..9)
20524                .map(|index| gate4agent_types::ActiveProviderTool {
20525                    id: format!("private-tool-id-{index}"),
20526                    name: if index == 0 {
20527                        format!("  Read\u{0000}{}", "x".repeat(80))
20528                    } else {
20529                        format!("Tool-{index}")
20530                    },
20531                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20532                })
20533                .collect(),
20534            interactions: vec![gate4agent_types::ProviderInteraction {
20535                id: gate4agent_types::ProviderInteractionId(17),
20536                source: source.clone(),
20537                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20538                interaction_kind: ProviderInteractionKind::Approval,
20539                tool_name: " Approve\u{0007}Tool ".to_owned(),
20540                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20541                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20542                resume_lead_activity: Some(ProviderActivity::Working),
20543                status: ProviderInteractionStatus::Pending,
20544            }],
20545            subagents: vec![gate4agent_types::ProviderSubagent {
20546                source: source.clone(),
20547                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20548                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20549                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20550            }],
20551            sources: vec![gate4agent_types::ProviderSourceCursor {
20552                source,
20553                sequence: 17,
20554                gap_count: 2,
20555                stale: true,
20556            }],
20557            last_event: Some(ProviderEvent::Error {
20558                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20559            }),
20560            gap_count: 2,
20561            stale: true,
20562        };
20563        let address = terminal_address(3);
20564        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20565        assert_eq!(entry.address, address);
20566        assert_eq!(entry.progress.provider_sequence, 17);
20567        assert_eq!(entry.progress.active_tool_count, 9);
20568        assert_eq!(
20569            entry.progress.active_tool_labels.len(),
20570            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20571        );
20572        assert_eq!(entry.progress.subagent_count, 1);
20573        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20574        assert!(entry.progress.truncated);
20575        let encoded = serde_json::to_string(&entry).unwrap();
20576        for forbidden in [
20577            "PRIVATE_PROVIDER_SESSION",
20578            "PRIVATE_PROVIDER_PATH",
20579            "PRIVATE_MODEL",
20580            "PRIVATE_TOOL_INVENTORY",
20581            "PRIVATE_CURRENT_PROMPT",
20582            "PRIVATE_TOOL_INPUT_JSON",
20583            "PRIVATE_PROVIDER_REQUEST",
20584            "PRIVATE_INTERACTION_PROMPT",
20585            "PRIVATE_INTERACTION_AGENT",
20586            "PRIVATE_SUBAGENT_ID",
20587            "PRIVATE_SUBAGENT_TYPE",
20588            "PRIVATE_SUBAGENT_DESCRIPTION",
20589            "PRIVATE_PROVIDER_ERROR",
20590            "input_json",
20591            "current_prompt",
20592            "session_id",
20593        ] {
20594            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20595        }
20596
20597        let snapshot = NodeSnapshot {
20598            node_id: NodeId::new("node-progress-test").unwrap(),
20599            enabled_providers: vec![agent("claude")],
20600            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20601            workspaces: Vec::new(),
20602            session_records: Vec::new(),
20603            managed_worktrees: Vec::new(),
20604            launch_inventory: None,
20605            agent_progress: vec![entry],
20606        };
20607        let mut legacy_reply = ResponseEnvelope {
20608            request_id: 1,
20609            result: Ok(NodeResponse::Snapshot {
20610                event_sequence: 0,
20611                controller: None,
20612                snapshot: snapshot.clone(),
20613            }),
20614        };
20615        project_response_without_agent_progress(&mut legacy_reply);
20616        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20617            panic!("agent progress projection changed response kind");
20618        };
20619        assert!(legacy.agent_progress.is_empty());
20620        assert_eq!(snapshot.agent_progress.len(), 1);
20621    }
20622
20623    fn terminal_address(generation: u64) -> SessionAddress {
20624        SessionAddress {
20625            workspace_id: WorkspaceId::new("primary").unwrap(),
20626            session: SessionKey {
20627                instance_id: AgentInstanceId(41),
20628                generation: SessionGeneration(generation),
20629            },
20630        }
20631    }
20632
20633    fn spawn_spec_fixture() -> SpawnSpec {
20634        SpawnSpec {
20635            target: crate::protocol::SpawnTarget {
20636                node_id: NodeId::new("node-terminal-test").unwrap(),
20637                workspace_id: WorkspaceId::new("primary").unwrap(),
20638                worktree_id: None,
20639            },
20640            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20641            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20642                .unwrap(),
20643            overrides: crate::protocol::SpawnOverrides::default(),
20644            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20645            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20646            required_capabilities: SpawnRequiredCapabilities::default(),
20647        }
20648    }
20649
20650    fn environment_profile_test_shared(
20651    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20652        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20653        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20654            "local-claude-r1",
20655        )
20656        .unwrap();
20657        let spawn_profiles = SpawnProfileRegistry::new([
20658            crate::protocol::SpawnProfileDefaults {
20659                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20660                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20661                provider: agent("claude"),
20662                mode: SessionMode::Pty,
20663                terminal_size: gate4agent_types::TerminalSize {
20664                    rows: 24,
20665                    columns: 80,
20666                },
20667                prompt: None,
20668                bundle_id: None,
20669                context_id: None,
20670                environment_profile_id: Some(profile_id.clone()),
20671            },
20672        ])
20673        .unwrap();
20674        let catalog = active_registry().unwrap();
20675        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20676        let control = runtime.native_launch_profile_control();
20677        let node_profile = NodeEnvironmentProfile::new(
20678            profile_id.clone(),
20679            profile_revision.clone(),
20680            agent("claude"),
20681            [NativeLaunchProfile::new(
20682                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20683                agent("claude"),
20684                TransportKind::Pty,
20685                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20686                Arc::new(EmptyEnvironmentResolver),
20687            )
20688            .unwrap()],
20689        )
20690        .unwrap();
20691        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20692        for native_profile in native_profiles {
20693            runtime.upsert_native_launch_profile(native_profile).unwrap();
20694        }
20695        let workspace = WorkspaceConfig::new(
20696            WorkspaceId::new("primary").unwrap(),
20697            std::env::current_dir().unwrap(),
20698        )
20699        .unwrap();
20700        let shared = NodeShared::new_with_incarnation(
20701            handle,
20702            "fixture-token".to_owned(),
20703            NodeId::new("node-terminal-test").unwrap(),
20704            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20705            vec![workspace],
20706            vec![agent("claude")],
20707            ProviderRuntimeStatuses::default(),
20708            None,
20709            Vec::new(),
20710            Vec::new(),
20711            spawn_profiles,
20712            Some(control),
20713            None,
20714            None,
20715            None,
20716            Vec::new(),
20717            Vec::new(),
20718            Vec::new(),
20719            Vec::new(),
20720            None,
20721            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20722        );
20723        shared
20724            .environment_profiles
20725            .write()
20726            .unwrap_or_else(|poisoned| poisoned.into_inner())
20727            .insert(binding.id.clone(), binding);
20728        (
20729            shared,
20730            runtime,
20731            ResolvedEnvironmentProfileReceipt {
20732                profile_id,
20733                profile_revision,
20734                network_allowlist: None,
20735                browser_profile_id: None,
20736            },
20737        )
20738    }
20739
20740    fn materialization_test_shared(
20741        deny_secret: bool,
20742    ) -> (
20743        NodeShared,
20744        NativeRuntime,
20745        ResolvedEnvironmentProfileReceipt,
20746        PathBuf,
20747        Arc<AtomicBool>,
20748    ) {
20749        let workspace = WorkspaceConfig::new(
20750            WorkspaceId::new("primary").unwrap(),
20751            std::env::current_dir().unwrap(),
20752        )
20753        .unwrap();
20754        materialization_test_shared_with_workspace(deny_secret, workspace)
20755    }
20756
20757    fn materialization_test_shared_with_workspace(
20758        deny_secret: bool,
20759        workspace: WorkspaceConfig,
20760    ) -> (
20761        NodeShared,
20762        NativeRuntime,
20763        ResolvedEnvironmentProfileReceipt,
20764        PathBuf,
20765        Arc<AtomicBool>,
20766    ) {
20767        let root = temporary_workspace_root(if deny_secret {
20768            "materialization-denied"
20769        } else {
20770            "materialization-ready"
20771        });
20772        std::fs::create_dir_all(&root).unwrap();
20773        let materialization_root = root.join("session-environments");
20774        let deny = Arc::new(AtomicBool::new(deny_secret));
20775        let resolver = Arc::new(FixtureSecretResolver {
20776            deny: Arc::clone(&deny),
20777        });
20778        let materializer = SessionEnvironmentMaterializer::new(
20779            materialization_root,
20780            resolver,
20781        )
20782        .unwrap();
20783        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20784        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20785            "materialized-claude-r1",
20786        )
20787        .unwrap();
20788        let materialization = NodeSessionMaterializationProfile::new(
20789            vec![NodeSessionEnvironmentMutation::SetSecret {
20790                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20791                reference: NodeSecretReference::new("fixture-token").unwrap(),
20792            }],
20793            vec![NodeSessionPathBinding::new(
20794                "GATE4AGENT_PROVIDER_HOME",
20795                NodeSessionPathClass::ProviderHome,
20796            )
20797            .unwrap()],
20798            vec![NodeSessionFile::secret(
20799                NodeSessionPathClass::Config,
20800                "auth/token",
20801                NodeSecretReference::new("fixture-token").unwrap(),
20802            )
20803            .unwrap()],
20804        )
20805        .unwrap();
20806        let catalog = active_registry().unwrap();
20807        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20808        let control = runtime.native_launch_profile_control();
20809        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20810            profile_id.clone(),
20811            profile_revision.clone(),
20812            agent("claude"),
20813            [NativeLaunchProfile::new(
20814                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20815                agent("claude"),
20816                TransportKind::Pty,
20817                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20818                Arc::new(EmptyEnvironmentResolver),
20819            )
20820            .unwrap()],
20821            Some(materialization),
20822        )
20823        .unwrap();
20824        let (binding, native_profiles, materialization) = node_profile.into_parts();
20825        for native_profile in native_profiles {
20826            runtime.upsert_native_launch_profile(native_profile).unwrap();
20827        }
20828        let shared = NodeShared::new_with_incarnation(
20829            handle,
20830            "fixture-token".to_owned(),
20831            NodeId::new("node-materialization-test").unwrap(),
20832            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20833            vec![workspace],
20834            vec![agent("claude")],
20835            ProviderRuntimeStatuses::default(),
20836            None,
20837            Vec::new(),
20838            Vec::new(),
20839            SpawnProfileRegistry::default(),
20840            Some(control),
20841            None,
20842            Some(materializer),
20843            None,
20844            Vec::new(),
20845            Vec::new(),
20846            Vec::new(),
20847            Vec::new(),
20848            None,
20849            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20850        );
20851        shared
20852            .environment_profiles
20853            .write()
20854            .unwrap_or_else(|poisoned| poisoned.into_inner())
20855            .insert(binding.id.clone(), binding);
20856        shared
20857            .environment_materialization_profiles
20858            .write()
20859            .unwrap_or_else(|poisoned| poisoned.into_inner())
20860            .insert(profile_id.clone(), materialization.unwrap());
20861        (
20862            shared,
20863            runtime,
20864            ResolvedEnvironmentProfileReceipt {
20865                profile_id,
20866                profile_revision,
20867                network_allowlist: None,
20868                browser_profile_id: None,
20869            },
20870            root,
20871            deny,
20872        )
20873    }
20874
20875    #[cfg(feature = "fixture")]
20876    #[derive(Clone, Copy)]
20877    enum CodexHomeProfile {
20878        Missing,
20879        WrongClass,
20880        Exact,
20881    }
20882
20883    #[cfg(feature = "fixture")]
20884    fn codex_bundle_test_shared(
20885        home_profile: CodexHomeProfile,
20886    ) -> (
20887        NodeShared,
20888        NativeRuntime,
20889        ResolvedEnvironmentProfileReceipt,
20890        NodeBundle,
20891        PathBuf,
20892    ) {
20893        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20894        const CLAUDE_MANIFEST: &[u8] =
20895            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20896        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20897        const DIGEST: &str =
20898            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20899
20900        let (shared, mut runtime, _claude_receipt, root, _deny) =
20901            materialization_test_shared(false);
20902        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20903            CodexHomeProfile::Missing => "codex-home-missing",
20904            CodexHomeProfile::WrongClass => "codex-home-wrong",
20905            CodexHomeProfile::Exact => "codex-home-exact",
20906        })
20907        .unwrap();
20908        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20909            "codex-home-r1",
20910        )
20911        .unwrap();
20912        let materialization = match home_profile {
20913            CodexHomeProfile::Missing => None,
20914            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20915                Some(NodeSessionMaterializationProfile::new(
20916                    Vec::new(),
20917                    vec![NodeSessionPathBinding::new(
20918                        "CODEX_HOME",
20919                        if matches!(home_profile, CodexHomeProfile::Exact) {
20920                            NodeSessionPathClass::ProviderHome
20921                        } else {
20922                            NodeSessionPathClass::Config
20923                        },
20924                    )
20925                    .unwrap()],
20926                    Vec::new(),
20927                )
20928                .unwrap())
20929            }
20930        };
20931        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20932            profile_id.clone(),
20933            profile_revision.clone(),
20934            agent("codex"),
20935            [NativeLaunchProfile::new(
20936                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20937                agent("codex"),
20938                TransportKind::Pty,
20939                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20940                Arc::new(EmptyEnvironmentResolver),
20941            )
20942            .unwrap()],
20943            materialization,
20944        )
20945        .unwrap();
20946        let (binding, native_profiles, materialization) = node_profile.into_parts();
20947        for native_profile in native_profiles {
20948            runtime.upsert_native_launch_profile(native_profile).unwrap();
20949        }
20950        shared
20951            .environment_profiles
20952            .write()
20953            .unwrap_or_else(|poisoned| poisoned.into_inner())
20954            .insert(binding.id.clone(), binding);
20955        if let Some(materialization) = materialization {
20956            shared
20957                .environment_materialization_profiles
20958                .write()
20959                .unwrap_or_else(|poisoned| poisoned.into_inner())
20960                .insert(profile_id.clone(), materialization);
20961        }
20962
20963        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20964        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20965        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20966        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20967        std::fs::write(
20968            bundle_source.join(".claude-plugin/plugin.json"),
20969            CLAUDE_MANIFEST,
20970        )
20971        .unwrap();
20972        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20973        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20974        let bundle = NodeBundle::new(
20975            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20976            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20977            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20978            &bundle_source,
20979        )
20980        .unwrap();
20981        *shared
20982            .bundle_catalog
20983            .write()
20984            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20985            BundleCatalog::new([bundle.clone()]).unwrap();
20986        (
20987            shared,
20988            runtime,
20989            ResolvedEnvironmentProfileReceipt {
20990                profile_id,
20991                profile_revision,
20992                network_allowlist: None,
20993                browser_profile_id: None,
20994            },
20995            bundle,
20996            root,
20997        )
20998    }
20999
21000    #[cfg(feature = "fixture")]
21001    #[test]
21002    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21003        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21004            let (shared, runtime, receipt, bundle, root) =
21005                codex_bundle_test_shared(home_profile);
21006            let error = shared
21007                .bundle_layout(
21008                    &agent("codex"),
21009                    SessionMode::Pty,
21010                    Some(&receipt),
21011                    &bundle,
21012                )
21013                .unwrap_err();
21014            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21015            assert!(shared
21016                .materializations
21017                .lock()
21018                .unwrap_or_else(|poisoned| poisoned.into_inner())
21019                .is_empty());
21020            let materialization_root = root.join("session-environments");
21021            assert!(!std::fs::read_dir(&materialization_root)
21022                .unwrap()
21023                .filter_map(Result::ok)
21024                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21025            drop(shared);
21026            drop(runtime);
21027            let _ = std::fs::remove_dir_all(root);
21028        }
21029    }
21030
21031    #[cfg(feature = "fixture")]
21032    #[test]
21033    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21034        let (shared, runtime, receipt, bundle, root) =
21035            codex_bundle_test_shared(CodexHomeProfile::Exact);
21036        let address = SessionAddress {
21037            workspace_id: WorkspaceId::new("primary").unwrap(),
21038            session: SessionKey {
21039                instance_id: AgentInstanceId(310),
21040                generation: SessionGeneration(1),
21041            },
21042        };
21043        let (overlay, guard) = shared
21044            .prepare_session_materialization(
21045                &address,
21046                &agent("codex"),
21047                SessionMode::Pty,
21048                Some(&receipt),
21049                Some(&bundle.receipt()),
21050                None,
21051                None,
21052            )
21053            .unwrap()
21054            .unwrap();
21055        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21056        let ownership = shared
21057            .materializations
21058            .lock()
21059            .unwrap_or_else(|poisoned| poisoned.into_inner())
21060            .get(guard.id().unwrap())
21061            .unwrap()
21062            .clone();
21063        let (profile, _, _) = shared
21064            .materialization_profile(
21065                &agent("codex"),
21066                SessionMode::Pty,
21067                Some(&receipt),
21068                Some(&bundle.receipt()),
21069                None,
21070            )
21071            .unwrap()
21072            .unwrap();
21073        let environment = shared
21074            .session_environment_materializer
21075            .as_ref()
21076            .unwrap()
21077            .resolve_environment(&ownership, &profile)
21078            .unwrap();
21079        assert!(environment.iter().any(|mutation| {
21080            mutation.key == OsString::from("CODEX_HOME")
21081                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21082        }));
21083        assert_eq!(
21084            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21085                .unwrap(),
21086            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21087        );
21088        assert!(std::fs::read_dir(ownership.bundle_root())
21089            .unwrap()
21090            .next()
21091            .is_none());
21092        drop(guard);
21093        drop(shared);
21094        drop(runtime);
21095        let _ = std::fs::remove_dir_all(root);
21096    }
21097
21098    #[cfg(feature = "fixture")]
21099    #[test]
21100    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21101        let (shared, runtime, receipt, bundle, root) =
21102            codex_bundle_test_shared(CodexHomeProfile::Exact);
21103        let bundle_receipt = bundle.receipt();
21104        let address = SessionAddress {
21105            workspace_id: WorkspaceId::new("primary").unwrap(),
21106            session: SessionKey {
21107                instance_id: AgentInstanceId(311),
21108                generation: SessionGeneration(1),
21109            },
21110        };
21111        let (_overlay, guard) = shared
21112            .prepare_session_materialization(
21113                &address,
21114                &agent("codex"),
21115                SessionMode::Pty,
21116                Some(&receipt),
21117                Some(&bundle_receipt),
21118                None,
21119                None,
21120            )
21121            .unwrap()
21122            .unwrap();
21123        let materialization_id = guard.id().unwrap().clone();
21124        let skill_path = shared
21125            .materializations
21126            .lock()
21127            .unwrap_or_else(|poisoned| poisoned.into_inner())
21128            .get(&materialization_id)
21129            .unwrap()
21130            .provider_home()
21131            .join("skills/review-code/SKILL.md");
21132        let record_id = shared
21133            .bind_spawn_session_with_materialization(
21134                &address,
21135                agent("codex"),
21136                SessionMode::Pty,
21137                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21138                SpawnRecordPolicy::ProviderIdentityOnly,
21139                Some(receipt.clone()),
21140                Some(bundle_receipt.clone()),
21141                None,
21142                Some(materialization_id.clone()),
21143            )
21144            .unwrap()
21145            .unwrap();
21146        guard.retain();
21147        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21148
21149        let error = match shared.resolve_record_materialization(
21150                &record_id,
21151                &agent("codex"),
21152                SessionMode::Pty,
21153                Some(&receipt),
21154                Some(&bundle_receipt),
21155                None,
21156            ) {
21157                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21158                Err(error) => error,
21159            };
21160        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21161        assert_eq!(
21162            shared
21163                .materializations
21164                .lock()
21165                .unwrap_or_else(|poisoned| poisoned.into_inner())
21166                .get(&materialization_id)
21167                .unwrap()
21168                .state(),
21169            MaterializationState::RecoveryRequired,
21170        );
21171        drop(shared);
21172        drop(runtime);
21173        let _ = std::fs::remove_dir_all(root);
21174    }
21175
21176    fn bind_record_owned_materialization_fixture(
21177        shared: &NodeShared,
21178        receipt: &ResolvedEnvironmentProfileReceipt,
21179        instance_id: u64,
21180    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21181        let address = SessionAddress {
21182            workspace_id: WorkspaceId::new("primary").unwrap(),
21183            session: SessionKey {
21184                instance_id: AgentInstanceId(instance_id),
21185                generation: SessionGeneration(1),
21186            },
21187        };
21188        let (overlay, materialization_guard) = shared
21189            .prepare_session_materialization(
21190                &address,
21191                &agent("claude"),
21192                SessionMode::Pty,
21193                Some(receipt),
21194                None,
21195                None,
21196                None,
21197            )
21198            .unwrap()
21199            .unwrap();
21200        let materialization_id = materialization_guard.id().unwrap().clone();
21201        let materialization_root = shared
21202            .materializations
21203            .lock()
21204            .unwrap_or_else(|poisoned| poisoned.into_inner())
21205            .get(&materialization_id)
21206            .unwrap()
21207            .root()
21208            .to_path_buf();
21209        let selection = shared
21210            .select_environment_profile(
21211                address.session.instance_id,
21212                &agent("claude"),
21213                SessionMode::Pty,
21214                Some(receipt),
21215            )
21216            .unwrap()
21217            .unwrap();
21218        assert!(shared
21219            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21220            .unwrap()
21221            .is_none());
21222        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21223        let record_id = shared
21224            .bind_spawn_session_with_materialization(
21225                &address,
21226                agent("claude"),
21227                SessionMode::Pty,
21228                policy,
21229                SpawnRecordPolicy::ProviderIdentityOnly,
21230                Some(receipt.clone()),
21231                None,
21232                None,
21233                Some(materialization_id.clone()),
21234            )
21235            .unwrap()
21236            .unwrap();
21237        selection.retain();
21238        materialization_guard.retain();
21239        (address, record_id, materialization_id, materialization_root)
21240    }
21241
21242    #[test]
21243    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21244        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21245        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21246            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21247        }));
21248        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21249        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21250        let mut spec = spawn_spec_fixture();
21251        spec.expected_profile_revision =
21252            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21253        assert!(matches!(
21254            &spec.overrides.environment_profile_id,
21255            crate::protocol::SpawnOverride::Inherit,
21256        ));
21257        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21258            .requires_child_environment_profile_capability());
21259        assert!(request_requires_child_environment_profile(
21260            &shared,
21261            &NodeRequest::SpawnSpec { spec: spec.clone() },
21262        ));
21263
21264        let address = terminal_address(1);
21265        shared.bind_session_with_policy(
21266            &address,
21267            ProviderRuntimePolicy::raw_pty(),
21268            Some(environment_profile.clone()),
21269        );
21270        assert!(request_requires_child_environment_profile(
21271            &shared,
21272            &NodeRequest::Input {
21273                session: address.clone(),
21274                text: "x".to_owned(),
21275            },
21276        ));
21277
21278        let mut record = record("claude", "profile-record");
21279        record.active_session = Some(address.clone());
21280        record.environment_profile = Some(environment_profile.clone());
21281        let record_id = record.record_id.clone();
21282        shared.insert_record(record.clone()).unwrap();
21283        assert!(request_requires_child_environment_profile(
21284            &shared,
21285            &NodeRequest::ResumeSessionRecord {
21286                record_id,
21287                terminal_size: gate4agent_types::TerminalSize {
21288                    rows: 24,
21289                    columns: 80,
21290                },
21291                initial_prompt: None,
21292            },
21293        ));
21294
21295        let snapshot = snapshot_for_wire(
21296            &shared, false, true, true, true, false, false, false, false, true,
21297        );
21298        assert_eq!(snapshot.session_records.len(), 1);
21299        assert!(snapshot.session_records[0].environment_profile.is_none());
21300        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21301            sequence: 1,
21302            event: NodeEvent::SessionRecordUpserted { record },
21303        });
21304        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21305            panic!("record projection changed the event kind");
21306        };
21307        assert!(record.environment_profile.is_none());
21308
21309        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21310        let mut reply = ResponseEnvelope {
21311            request_id: 1,
21312            result: Ok(NodeResponse::SpawnSpecAccepted {
21313                receipt: resolved.receipt_with_environment(
21314                    shared.incarnation_id,
21315                    address,
21316                    Some(environment_profile),
21317                ),
21318            }),
21319        };
21320        project_response_without_child_environment_profile(&mut reply);
21321        assert_eq!(
21322            reply.result.unwrap_err().code,
21323            NodeFailureCode::UnsupportedCapability,
21324        );
21325    }
21326
21327    #[test]
21328    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21329        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21330        let snapshot = shared.snapshot();
21331        let profiles = snapshot
21332            .launch_inventory
21333            .as_ref()
21334            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21335            .unwrap();
21336        assert_eq!(profiles.len(), 1);
21337        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21338        assert!(snapshot.requires_child_environment_profile_capability());
21339
21340        let mut legacy = snapshot.clone();
21341        clear_snapshot_child_environment_profiles(&mut legacy);
21342        assert!(legacy
21343            .launch_inventory
21344            .as_ref()
21345            .unwrap()
21346            .spawn_profiles
21347            .as_ref()
21348            .unwrap()[0]
21349            .environment_profile
21350            .is_none());
21351        assert!(!legacy.requires_child_environment_profile_capability());
21352
21353        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21354        let bindings = shared
21355            .environment_profiles
21356            .read()
21357            .unwrap_or_else(|poisoned| poisoned.into_inner())
21358            .clone();
21359        let mut provider_mismatch = bindings.clone();
21360        provider_mismatch
21361            .values_mut()
21362            .next()
21363            .unwrap()
21364            .provider = agent("codex");
21365        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21366        let mut mode_mismatch = profile;
21367        mode_mismatch.mode = SessionMode::Inline;
21368        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21369
21370        shared
21371            .environment_profiles
21372            .write()
21373            .unwrap_or_else(|poisoned| poisoned.into_inner())
21374            .clear();
21375        assert!(shared
21376            .snapshot()
21377            .launch_inventory
21378            .unwrap()
21379            .spawn_profiles
21380            .unwrap()
21381            .is_empty());
21382    }
21383
21384    #[test]
21385    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21386        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21387            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21388        }));
21389        let shared = terminal_test_shared();
21390        let bundle = ResolvedBundleReceipt {
21391            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21392            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21393            digest: crate::protocol::SpawnBundleDigest::new(format!(
21394                "sha256:{}",
21395                "0".repeat(64),
21396            ))
21397            .unwrap(),
21398        };
21399        let mut bundled = record("claude", "bundle-record");
21400        bundled.bundle = Some(bundle.clone());
21401        let record_id = bundled.record_id.clone();
21402        shared.insert_record(bundled.clone()).unwrap();
21403        assert!(request_requires_session_bundle(
21404            &shared,
21405            &NodeRequest::ForgetSessionRecord {
21406                record_id: record_id.clone(),
21407            },
21408        ));
21409
21410        let snapshot = snapshot_for_wire(
21411            &shared, false, true, true, true, true, false, false, false, true,
21412        );
21413        assert!(snapshot.session_records[0].bundle.is_none());
21414        let launch = snapshot.launch_inventory.as_ref().unwrap();
21415        assert!(launch.spawn_profiles.is_some());
21416        assert!(launch.bundles.is_none());
21417        let event = project_event_without_session_bundle(NodeEventEnvelope {
21418            sequence: 1,
21419            event: NodeEvent::SessionRecordUpserted { record: bundled },
21420        });
21421        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21422            panic!("record projection changed the event kind");
21423        };
21424        assert!(record.bundle.is_none());
21425
21426        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21427        let mut reply = ResponseEnvelope {
21428            request_id: 1,
21429            result: Ok(NodeResponse::SpawnSpecAccepted {
21430                receipt: resolved.receipt_with_materialization(
21431                    shared.incarnation_id,
21432                    terminal_address(17),
21433                    None,
21434                    Some(bundle),
21435                    None,
21436                ),
21437            }),
21438        };
21439        project_response_without_session_bundle(&mut reply);
21440        assert_eq!(
21441            reply.result.unwrap_err().code,
21442            NodeFailureCode::UnsupportedCapability,
21443        );
21444    }
21445
21446    #[test]
21447    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21448        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21449            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21450        }));
21451        let context_id = SpawnContextId::new("context-review").unwrap();
21452        let context = ResolvedContextPackReceipt {
21453            id: context_id.clone(),
21454            digest: crate::protocol::SpawnContextDigest::new(format!(
21455                "sha256:{}",
21456                "c".repeat(64),
21457            ))
21458            .unwrap(),
21459            lineage: ContextPackLineageReceipt {
21460                source_node_id: NodeId::new("node-source").unwrap(),
21461                source_session: terminal_address(1),
21462                source_provider: agent("kimi"),
21463            },
21464            source_message_count: 2,
21465            retained_message_count: 2,
21466            byte_len: 256,
21467            truncated: false,
21468        };
21469        assert!(context.is_valid());
21470        let mut bound_record = record("codex", "context-record");
21471        bound_record.context_id = Some(context_id);
21472        bound_record.context = Some(context.clone());
21473        let session = gate4agent_types::SessionSnapshot {
21474            instance_id: AgentInstanceId(91),
21475            agent_id: agent("kimi"),
21476            transport: TransportKind::Pty,
21477            generation: SessionGeneration(3),
21478            status: SessionStatus::Running,
21479            pending_operation: None,
21480            pending_input: None,
21481            process_id: Some(9001),
21482            terminal_size: None,
21483            terminal_frame: None,
21484            terminal_stale: None,
21485            session_options: None,
21486            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21487            history: gate4agent_types::HistorySnapshot {
21488                pending: None,
21489                candidates: vec![HistoryCandidateSummary {
21490                    id: "candidate-opaque".to_owned(),
21491                    session_id_hint: "session-hint".to_owned(),
21492                    modified_at_unix_ms: Some(1),
21493                }],
21494                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21495                loaded: Some(HistorySessionRecord {
21496                    session_id: "private-vendor-session".to_owned(),
21497                    title: Some("private title".to_owned()),
21498                    cwd: Some(r"C:\private\history-root".to_owned()),
21499                    model: Some("private-model".to_owned()),
21500                    message_count: 2,
21501                    completed_turn_count: None,
21502                    total_tokens: 19,
21503                    messages: vec![
21504                        gate4agent_types::HistoryMessageRecord {
21505                            role: gate4agent_types::HistoryMessageRole::User,
21506                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21507                        },
21508                        gate4agent_types::HistoryMessageRecord {
21509                            role: gate4agent_types::HistoryMessageRole::Assistant,
21510                            text: "private answer".to_owned(),
21511                        },
21512                    ],
21513                }),
21514                last_error: None,
21515            },
21516            resume: gate4agent_types::ResumeSnapshot::default(),
21517            foreground: gate4agent_types::ForegroundSnapshot::default(),
21518            provider: gate4agent_types::ProviderSnapshot::default(),
21519            screen_state: gate4agent_types::PtyScreenState::default(),
21520        };
21521        let original = NodeSnapshot {
21522            node_id: NodeId::new("node-terminal-test").unwrap(),
21523            enabled_providers: vec![agent("kimi"), agent("codex")],
21524            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21525            workspaces: vec![WorkspaceSnapshot {
21526                workspace_id: WorkspaceId::new("primary").unwrap(),
21527                canonical_root: opaque_windows_path(
21528                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21529                ),
21530                sessions: vec![session],
21531                worktree_service_mode: None,
21532                managed_worktree_profiles: None,
21533            }],
21534            session_records: vec![bound_record],
21535            managed_worktrees: Vec::new(),
21536            launch_inventory: None,
21537            agent_progress: Vec::new(),
21538        };
21539
21540        let mut negotiated = original.clone();
21541        project_snapshot_history_for_wire(&mut negotiated, true);
21542        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21543        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21544        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21545        assert!(!serde_json::to_string(&negotiated)
21546            .unwrap()
21547            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21548
21549        let mut legacy = original;
21550        project_snapshot_history_for_wire(&mut legacy, false);
21551        clear_snapshot_context_packs(&mut legacy);
21552        assert_eq!(
21553            legacy.workspaces[0].sessions[0].history,
21554            gate4agent_types::HistorySnapshot::default(),
21555        );
21556        assert!(legacy.session_records[0].context_id.is_none());
21557        assert!(legacy.session_records[0].context.is_none());
21558        let encoded = serde_json::to_string(&legacy).unwrap();
21559        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21560        assert!(!encoded.contains("context-review"));
21561
21562        let mut reply = ResponseEnvelope {
21563            request_id: 7,
21564            result: Ok(NodeResponse::ContextPackExported { context }),
21565        };
21566        project_response_without_context_pack(&mut reply);
21567        assert_eq!(
21568            reply.result.unwrap_err().code,
21569            NodeFailureCode::UnsupportedCapability,
21570        );
21571    }
21572
21573    #[test]
21574    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21575        let shared = terminal_test_shared();
21576        let history = HistorySessionRecord {
21577            session_id: "fixture-history".to_owned(),
21578            title: None,
21579            cwd: None,
21580            model: None,
21581            message_count: 1,
21582            completed_turn_count: None,
21583            total_tokens: 0,
21584            messages: vec![gate4agent_types::HistoryMessageRecord {
21585                role: gate4agent_types::HistoryMessageRole::User,
21586                text: "bounded handoff".to_owned(),
21587            }],
21588        };
21589        let pack = NodeContextPack::export(
21590            ContextPackLineageReceipt {
21591                source_node_id: shared.node_id.clone(),
21592                source_session: terminal_address(1),
21593                source_provider: agent("claude"),
21594            },
21595            &history,
21596        )
21597        .unwrap();
21598        let context = shared
21599            .context_catalog
21600            .write()
21601            .unwrap_or_else(|poisoned| poisoned.into_inner())
21602            .insert(pack)
21603            .unwrap();
21604        let mut retained = record("codex", "context-retained-record");
21605        retained.context_id = Some(context.id.clone());
21606        retained.context = Some(context.clone());
21607        let record_id = retained.record_id.clone();
21608        shared.insert_record(retained).unwrap();
21609        assert_eq!(
21610            shared.forget_context_pack(&context.id).unwrap_err().code,
21611            NodeFailureCode::ContextPackBusy,
21612        );
21613        assert!(shared
21614            .context_catalog
21615            .read()
21616            .unwrap_or_else(|poisoned| poisoned.into_inner())
21617            .get(&context.id)
21618            .is_some());
21619        shared
21620            .session_records
21621            .lock()
21622            .unwrap_or_else(|poisoned| poisoned.into_inner())
21623            .records
21624            .remove(&record_id);
21625
21626        let mut spec = spawn_spec_fixture();
21627        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21628            value: context.id.clone(),
21629        };
21630        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21631        let receipt = resolved.receipt_with_materialization(
21632            shared.incarnation_id,
21633            terminal_address(2),
21634            None,
21635            None,
21636            Some(context.clone()),
21637        );
21638        assert!(receipt.context_binding_is_valid());
21639        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21640
21641        shared.forget_context_pack(&context.id).unwrap();
21642        assert!(shared
21643            .context_catalog
21644            .read()
21645            .unwrap_or_else(|poisoned| poisoned.into_inner())
21646            .get(&context.id)
21647            .is_none());
21648        assert!(shared
21649            .replay_spawn_spec(&spec, Instant::now())
21650            .unwrap()
21651            .is_none());
21652    }
21653
21654    #[test]
21655    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21656        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21657        let address = terminal_address(1);
21658        let selection = shared
21659            .select_environment_profile(
21660                address.session.instance_id,
21661                &agent("claude"),
21662                SessionMode::Pty,
21663                Some(&environment_profile),
21664            )
21665            .unwrap()
21666            .unwrap();
21667        shared.bind_session_with_policy(
21668            &address,
21669            ProviderRuntimePolicy::raw_pty(),
21670            Some(environment_profile.clone()),
21671        );
21672        selection.retain();
21673        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21674        assert!(matches!(
21675            runtime.remove_native_launch_profile(&native_id),
21676            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21677        ));
21678        assert!(shared.remove_binding(&address).is_some());
21679        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21680
21681        let mismatched = ResolvedEnvironmentProfileReceipt {
21682            profile_id: environment_profile.profile_id,
21683            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21684                "local-claude-r2",
21685            )
21686            .unwrap(),
21687            network_allowlist: None,
21688            browser_profile_id: None,
21689        };
21690        let error = match shared.select_environment_profile(
21691                AgentInstanceId(42),
21692                &agent("claude"),
21693                SessionMode::Pty,
21694                Some(&mismatched),
21695            ) {
21696            Ok(_) => panic!("mismatched environment profile revision was selected"),
21697            Err(error) => error,
21698        };
21699        assert_eq!(
21700            error.code,
21701            NodeFailureCode::EnvironmentProfileBindingMismatch,
21702        );
21703        assert!(!shared
21704            .session_bindings
21705            .lock()
21706            .unwrap_or_else(|poisoned| poisoned.into_inner())
21707            .contains_key(&AgentInstanceId(42)));
21708    }
21709
21710    #[test]
21711    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21712        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21713        let address = terminal_address(1);
21714        let selection = shared
21715            .select_environment_profile(
21716                address.session.instance_id,
21717                &agent("claude"),
21718                SessionMode::Pty,
21719                Some(&environment_profile),
21720            )
21721            .unwrap()
21722            .unwrap();
21723        let overlay = NativeInstanceLaunchOverlay::new(
21724            agent("claude"),
21725            TransportKind::Pty,
21726            vec![EnvMutation {
21727                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21728                value: Some(OsString::from("fixture-secret")),
21729            }],
21730            vec![
21731                OsString::from("--plugin-dir"),
21732                std::env::current_dir().unwrap().into_os_string(),
21733            ],
21734        )
21735        .unwrap();
21736        let instance_overlay = shared
21737            .install_prepared_launch_overlay(
21738                address.session.instance_id,
21739                PreparedNativeLaunchOverlay::Instance(overlay),
21740            )
21741            .unwrap()
21742            .unwrap();
21743        let bundle = ResolvedBundleReceipt {
21744            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21745            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21746            digest: crate::protocol::SpawnBundleDigest::new(format!(
21747                "sha256:{}",
21748                "0".repeat(64),
21749            ))
21750            .unwrap(),
21751        };
21752        shared.bind_session_with_materialization(
21753            &address,
21754            ProviderRuntimePolicy::raw_pty(),
21755            Some(environment_profile),
21756            Some(bundle),
21757            None,
21758            None,
21759        );
21760        selection.retain();
21761        instance_overlay.retain();
21762
21763        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21764        assert!(matches!(
21765            runtime.remove_native_launch_profile(&native_id),
21766            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21767        ));
21768        assert!(shared.remove_binding(&address).is_some());
21769        assert!(!shared
21770            .native_launch_profile_control
21771            .as_ref()
21772            .unwrap()
21773            .clear_native_instance_launch_overlay(address.session.instance_id));
21774        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21775    }
21776
21777    #[test]
21778    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21779        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21780        let address = SessionAddress {
21781            workspace_id: WorkspaceId::new("primary").unwrap(),
21782            session: SessionKey {
21783                instance_id: AgentInstanceId(201),
21784                generation: SessionGeneration(1),
21785            },
21786        };
21787        let error = match shared.prepare_session_materialization(
21788            &address,
21789            &agent("claude"),
21790            SessionMode::Pty,
21791            Some(&receipt),
21792            None,
21793            None,
21794            None,
21795        ) {
21796            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21797            Err(error) => error,
21798        };
21799        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21800        assert!(shared.handle.snapshot().sessions.is_empty());
21801        assert!(shared
21802            .session_bindings
21803            .lock()
21804            .unwrap_or_else(|poisoned| poisoned.into_inner())
21805            .is_empty());
21806        assert!(shared
21807            .materializations
21808            .lock()
21809            .unwrap_or_else(|poisoned| poisoned.into_inner())
21810            .is_empty());
21811        drop(shared);
21812        drop(runtime);
21813        std::fs::remove_dir_all(root).unwrap();
21814    }
21815
21816    #[test]
21817    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21818        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21819        let address = SessionAddress {
21820            workspace_id: WorkspaceId::new("primary").unwrap(),
21821            session: SessionKey {
21822                instance_id: AgentInstanceId(202),
21823                generation: SessionGeneration(1),
21824            },
21825        };
21826        let (overlay, materialization_guard) = shared
21827            .prepare_session_materialization(
21828                &address,
21829                &agent("claude"),
21830                SessionMode::Pty,
21831                Some(&receipt),
21832                None,
21833                None,
21834                None,
21835            )
21836            .unwrap()
21837            .unwrap();
21838        let materialization_id = materialization_guard.id().unwrap().clone();
21839        let materialization_root = shared
21840            .materializations
21841            .lock()
21842            .unwrap_or_else(|poisoned| poisoned.into_inner())
21843            .get(&materialization_id)
21844            .unwrap()
21845            .root()
21846            .to_path_buf();
21847        let selection = shared
21848            .select_environment_profile(
21849                address.session.instance_id,
21850                &agent("claude"),
21851                SessionMode::Pty,
21852                Some(&receipt),
21853            )
21854            .unwrap()
21855            .unwrap();
21856        assert!(shared
21857            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21858            .unwrap()
21859            .is_none());
21860        shared.bind_session_with_materialization(
21861            &address,
21862            ProviderRuntimePolicy::raw_pty(),
21863            Some(receipt),
21864            None,
21865            None,
21866            Some(materialization_id),
21867        );
21868        selection.retain();
21869        materialization_guard.retain();
21870
21871        assert!(materialization_root.is_dir());
21872        assert!(shared
21873            .session_bindings
21874            .lock()
21875            .unwrap_or_else(|poisoned| poisoned.into_inner())
21876            .contains_key(&address.session.instance_id));
21877
21878        let removed = shared.remove_binding(&address).unwrap();
21879        shared
21880            .cleanup_session_owned_materialization(&address, &removed)
21881            .unwrap();
21882        assert!(!materialization_root.exists());
21883        assert!(shared
21884            .materializations
21885            .lock()
21886            .unwrap_or_else(|poisoned| poisoned.into_inner())
21887            .is_empty());
21888        drop(shared);
21889        drop(runtime);
21890        std::fs::remove_dir_all(root).unwrap();
21891    }
21892
21893    #[tokio::test]
21894    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21895        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21896        let address = SessionAddress {
21897            workspace_id: WorkspaceId::new("primary").unwrap(),
21898            session: SessionKey {
21899                instance_id: AgentInstanceId(203),
21900                generation: SessionGeneration(1),
21901            },
21902        };
21903        let (overlay, materialization_guard) = shared
21904            .prepare_session_materialization(
21905                &address,
21906                &agent("claude"),
21907                SessionMode::Pty,
21908                Some(&receipt),
21909                None,
21910                None,
21911                None,
21912            )
21913            .unwrap()
21914            .unwrap();
21915        let materialization_id = materialization_guard.id().unwrap().clone();
21916        let materialization_root = shared
21917            .materializations
21918            .lock()
21919            .unwrap_or_else(|poisoned| poisoned.into_inner())
21920            .get(&materialization_id)
21921            .unwrap()
21922            .root()
21923            .to_path_buf();
21924        let selection = shared
21925            .select_environment_profile(
21926                address.session.instance_id,
21927                &agent("claude"),
21928                SessionMode::Pty,
21929                Some(&receipt),
21930            )
21931            .unwrap()
21932            .unwrap();
21933        assert!(shared
21934            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21935            .unwrap()
21936            .is_none());
21937        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21938        let record_id = shared
21939            .bind_spawn_session_with_materialization(
21940                &address,
21941                agent("claude"),
21942                SessionMode::Pty,
21943                policy,
21944                SpawnRecordPolicy::ProviderIdentityOnly,
21945                Some(receipt.clone()),
21946                None,
21947                None,
21948                Some(materialization_id.clone()),
21949            )
21950            .unwrap()
21951            .unwrap();
21952        selection.retain();
21953        materialization_guard.retain();
21954        assert_eq!(
21955            shared
21956                .materializations
21957                .lock()
21958                .unwrap_or_else(|poisoned| poisoned.into_inner())
21959                .get(&materialization_id)
21960                .unwrap()
21961                .owner(),
21962            &MaterializationOwner::Record {
21963                record_id: record_id.clone(),
21964            },
21965        );
21966
21967        let removed = shared.remove_binding(&address).unwrap();
21968        shared
21969            .cleanup_session_owned_materialization(&address, &removed)
21970            .unwrap();
21971        assert!(materialization_root.is_dir());
21972        assert!(shared
21973            .resolve_record_materialization(
21974                &record_id,
21975                &agent("claude"),
21976                SessionMode::Pty,
21977                Some(&receipt),
21978                None,
21979                None,
21980            )
21981            .unwrap()
21982            .is_some());
21983        {
21984            let mut records = shared
21985                .session_records
21986                .lock()
21987                .unwrap_or_else(|poisoned| poisoned.into_inner());
21988            let record = records.records.get_mut(&record_id).unwrap();
21989            record.active_session = None;
21990            record.state = ManagedSessionState::Unavailable;
21991        }
21992        shared.forget_session_record(&record_id).await.unwrap();
21993        assert!(!materialization_root.exists());
21994        assert!(shared
21995            .materializations
21996            .lock()
21997            .unwrap_or_else(|poisoned| poisoned.into_inner())
21998            .is_empty());
21999        drop(shared);
22000        drop(runtime);
22001        std::fs::remove_dir_all(root).unwrap();
22002    }
22003
22004    #[test]
22005    fn record_materialization_revalidation_failure_persists_recovery_required() {
22006        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22007        let state_path = root.join("node-state-v6.json");
22008        shared.state_path = Some(state_path.clone());
22009        let (_address, _record_id, materialization_id, materialization_root) =
22010            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22011        std::fs::write(
22012            materialization_root.join(".gate4agent-materialization-owner"),
22013            b"tampered",
22014        )
22015        .unwrap();
22016
22017        let error = match shared.resolve_record_materialization(
22018            &_record_id,
22019            &agent("claude"),
22020            SessionMode::Pty,
22021            Some(&receipt),
22022            None,
22023            None,
22024        ) {
22025            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22026            Err(error) => error,
22027        };
22028        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22029        assert_eq!(
22030            shared
22031                .materializations
22032                .lock()
22033                .unwrap_or_else(|poisoned| poisoned.into_inner())
22034                .get(&materialization_id)
22035                .unwrap()
22036                .state(),
22037            MaterializationState::RecoveryRequired,
22038        );
22039        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22040        assert_eq!(loaded.materializations.len(), 1);
22041        assert_eq!(
22042            loaded.materializations[0].state(),
22043            MaterializationState::RecoveryRequired,
22044        );
22045        drop(shared);
22046        drop(runtime);
22047        std::fs::remove_dir_all(root).unwrap();
22048    }
22049
22050    #[test]
22051    fn transient_record_materialization_resolution_failure_remains_ready() {
22052        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22053        let (_address, record_id, materialization_id, _materialization_root) =
22054            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22055        deny.store(true, Ordering::Release);
22056
22057        let error = match shared.resolve_record_materialization(
22058            &record_id,
22059            &agent("claude"),
22060            SessionMode::Pty,
22061            Some(&receipt),
22062            None,
22063            None,
22064        ) {
22065            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22066            Err(error) => error,
22067        };
22068        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22069        assert_eq!(
22070            shared
22071                .materializations
22072                .lock()
22073                .unwrap_or_else(|poisoned| poisoned.into_inner())
22074                .get(&materialization_id)
22075                .unwrap()
22076                .state(),
22077            MaterializationState::Ready,
22078        );
22079        drop(shared);
22080        drop(runtime);
22081        std::fs::remove_dir_all(root).unwrap();
22082    }
22083
22084    #[test]
22085    fn startup_missing_record_materialization_is_not_resumable() {
22086        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22087        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22088        shared
22089            .insert_record(ManagedSessionRecord {
22090                record_id: record_id.clone(),
22091                display_name: "missing materialization".to_owned(),
22092                provider: agent("claude"),
22093                mode: SessionMode::Pty,
22094                state: ManagedSessionState::Dormant,
22095                workspace_id: WorkspaceId::new("primary").unwrap(),
22096                canonical_root: opaque_windows_path(
22097                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22098                ),
22099                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22100                    key: gate4agent_types::ProviderSessionKey::SessionId,
22101                    id: "provider-session-missing-materialization".to_owned(),
22102                    transcript_path: None,
22103                }),
22104                active_session: None,
22105                environment_profile: Some(receipt),
22106                bundle: None,
22107                context_id: None,
22108                context: None,
22109                exported_context: None,
22110                task_binding: None,
22111                created_at_unix_ms: 1,
22112                updated_at_unix_ms: 1,
22113                last_error: None,
22114            })
22115            .unwrap();
22116
22117        shared.reconcile_materializations();
22118        let record = shared.record(&record_id).unwrap();
22119        assert_eq!(record.state, ManagedSessionState::Unavailable);
22120        assert!(record.provider_session.is_none());
22121        assert_eq!(
22122            record.last_error.as_deref(),
22123            Some("environment-profile-unavailable"),
22124        );
22125        drop(shared);
22126        drop(runtime);
22127        std::fs::remove_dir_all(root).unwrap();
22128    }
22129
22130    #[tokio::test]
22131    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22132        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22133        let lease = install_managed_lease(&shared);
22134        let address = SessionAddress {
22135            workspace_id: WorkspaceId::new("primary").unwrap(),
22136            session: SessionKey {
22137                instance_id: AgentInstanceId(204),
22138                generation: SessionGeneration(1),
22139            },
22140        };
22141        let (overlay, materialization_guard) = shared
22142            .prepare_session_materialization(
22143                &address,
22144                &agent("claude"),
22145                SessionMode::Pty,
22146                Some(&receipt),
22147                None,
22148                None,
22149                Some(lease.lease_id.clone()),
22150            )
22151            .unwrap()
22152            .unwrap();
22153        let materialization_id = materialization_guard.id().unwrap().clone();
22154        let materialization_root = shared
22155            .materializations
22156            .lock()
22157            .unwrap_or_else(|poisoned| poisoned.into_inner())
22158            .get(&materialization_id)
22159            .unwrap()
22160            .root()
22161            .to_path_buf();
22162        let selection = shared
22163            .select_environment_profile(
22164                address.session.instance_id,
22165                &agent("claude"),
22166                SessionMode::Pty,
22167                Some(&receipt),
22168            )
22169            .unwrap()
22170            .unwrap();
22171        assert!(shared
22172            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22173            .unwrap()
22174            .is_none());
22175        shared.bind_session_with_materialization(
22176            &address,
22177            ProviderRuntimePolicy::raw_pty(),
22178            Some(receipt),
22179            None,
22180            None,
22181            Some(materialization_id),
22182        );
22183        selection.retain();
22184        materialization_guard.retain();
22185
22186        let blocked = shared
22187            .cleanup_managed_worktree(&lease.lease_id, false)
22188            .await
22189            .unwrap_err();
22190        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22191        assert!(materialization_root.is_dir());
22192        let removed = shared.remove_binding(&address).unwrap();
22193        shared
22194            .cleanup_session_owned_materialization(&address, &removed)
22195            .unwrap();
22196        assert!(!materialization_root.exists());
22197        drop(shared);
22198        drop(runtime);
22199        std::fs::remove_dir_all(root).unwrap();
22200    }
22201
22202    #[tokio::test]
22203    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22204        let git_root = temporary_workspace_root("raw-managed-inventory");
22205        let source = git_root.join("source");
22206        let allocation = git_root.join("allocation");
22207        std::fs::create_dir_all(&source).unwrap();
22208        std::fs::create_dir_all(&allocation).unwrap();
22209        run_test_git(&source, &["init"]);
22210        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22211        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22212        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22213        run_test_git(&source, &["add", "seed.txt"]);
22214        run_test_git(&source, &["commit", "-m", "seed"]);
22215        let profile = ManagedWorktreeProfile::new(
22216            WorktreeProfileId::new("default").unwrap(),
22217            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22218            &allocation,
22219            "gate4agent",
22220            "HEAD",
22221            ManagedWorktreeRetention::RemoveWhenReleased,
22222        )
22223        .unwrap();
22224        let workspace = WorkspaceConfig::new(
22225            WorkspaceId::new("primary").unwrap(),
22226            &source,
22227        )
22228        .unwrap()
22229        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22230        .with_managed_worktree_profile(profile.clone())
22231        .unwrap();
22232        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22233            materialization_test_shared_with_workspace(false, workspace);
22234        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22235        let source_text = source.to_string_lossy().into_owned();
22236        let target_text = target.to_string_lossy().into_owned();
22237        let created = create_git_worktree(
22238            &source_text,
22239            &target_text,
22240            "gate4agent/mw-raw-inventory",
22241            Some("HEAD"),
22242        )
22243        .await
22244        .unwrap();
22245        let lease = ManagedWorktreeLeaseRecord {
22246            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22247            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22248            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22249            profile_id: profile.profile_id().clone(),
22250            profile_revision: profile.revision().clone(),
22251            target_root: created.path.clone(),
22252            branch: "gate4agent/mw-raw-inventory".to_owned(),
22253            base_commit: created.head.clone(),
22254            expected_head: Some(created.head.clone()),
22255            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22256            state: ManagedWorktreeLeaseState::Ready,
22257            session_holders: Vec::new(),
22258            record_holders: Vec::new(),
22259            cleanup_failure: None,
22260            created_at_unix_ms: 1,
22261            updated_at_unix_ms: 1,
22262        };
22263        shared
22264            .workspaces
22265            .write()
22266            .unwrap_or_else(|poisoned| poisoned.into_inner())
22267            .insert(lease.workspace_id.clone(), created.path.clone());
22268        *shared
22269            .managed_worktrees
22270            .lock()
22271            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22272            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22273        let address = SessionAddress {
22274            workspace_id: lease.workspace_id.clone(),
22275            session: SessionKey {
22276                instance_id: AgentInstanceId(205),
22277                generation: SessionGeneration(1),
22278            },
22279        };
22280        let (overlay, materialization_guard) = shared
22281            .prepare_session_materialization(
22282                &address,
22283                &agent("claude"),
22284                SessionMode::Pty,
22285                Some(&receipt),
22286                None,
22287                None,
22288                Some(lease.lease_id.clone()),
22289            )
22290            .unwrap()
22291            .unwrap();
22292        let materialization_id = materialization_guard.id().unwrap().clone();
22293        let materialization_root = shared
22294            .materializations
22295            .lock()
22296            .unwrap_or_else(|poisoned| poisoned.into_inner())
22297            .get(&materialization_id)
22298            .unwrap()
22299            .root()
22300            .to_path_buf();
22301        let selection = shared
22302            .select_environment_profile(
22303                address.session.instance_id,
22304                &agent("claude"),
22305                SessionMode::Pty,
22306                Some(&receipt),
22307            )
22308            .unwrap()
22309            .unwrap();
22310        assert!(shared
22311            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22312            .unwrap()
22313            .is_none());
22314        let record_id = shared
22315            .bind_spawn_session_with_materialization(
22316                &address,
22317                agent("claude"),
22318                SessionMode::Pty,
22319                ProviderRuntimePolicy::raw_pty(),
22320                SpawnRecordPolicy::Always,
22321                Some(receipt.clone()),
22322                None,
22323                None,
22324                Some(materialization_id.clone()),
22325            )
22326            .unwrap()
22327            .unwrap();
22328        selection.retain();
22329        materialization_guard.retain();
22330        let record = shared.record(&record_id).unwrap();
22331        assert_eq!(record.state, ManagedSessionState::Live);
22332        assert!(record.provider_session.is_none());
22333        assert!(record.environment_profile.is_none());
22334        assert!(record.bundle.is_none());
22335        assert!(record.context_id.is_none());
22336        assert!(record.context.is_none());
22337        assert_eq!(record.active_session.as_ref(), Some(&address));
22338        assert_eq!(
22339            shared
22340                .materializations
22341                .lock()
22342                .unwrap_or_else(|poisoned| poisoned.into_inner())
22343                .get(&materialization_id)
22344                .unwrap()
22345                .owner(),
22346            &MaterializationOwner::Session {
22347                incarnation_id: shared.incarnation_id,
22348                instance_id: address.session.instance_id,
22349                generation: address.session.generation,
22350            },
22351        );
22352        {
22353            let mut bindings = shared
22354                .session_bindings
22355                .lock()
22356                .unwrap_or_else(|poisoned| poisoned.into_inner());
22357            bindings
22358                .get_mut(&address.session.instance_id)
22359                .unwrap()
22360                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22361        }
22362        let lease_snapshot = shared
22363            .managed_worktrees
22364            .lock()
22365            .unwrap_or_else(|poisoned| poisoned.into_inner())
22366            .bind_session(
22367                &lease.lease_id,
22368                ManagedWorktreeSessionHolder {
22369                    incarnation_id: shared.incarnation_id,
22370                    instance_id: address.session.instance_id,
22371                    generation: address.session.generation,
22372                },
22373                None,
22374                unix_time_ms(),
22375            )
22376            .unwrap();
22377        assert_eq!(lease_snapshot.active_session_count, 1);
22378        assert_eq!(lease_snapshot.managed_record_count, 0);
22379        shared.persist_state().unwrap();
22380        assert!(shared
22381            .managed_worktrees
22382            .lock()
22383            .unwrap_or_else(|poisoned| poisoned.into_inner())
22384            .get(&lease.lease_id)
22385            .unwrap()
22386            .record_holders
22387            .is_empty());
22388        shared
22389            .handle
22390            .dispatch(shared.prepare_command(ControlCommand::Register {
22391                instance_id: address.session.instance_id,
22392                agent_id: agent("claude"),
22393                transport: TransportKind::Pty,
22394            }))
22395            .unwrap();
22396        runtime.tick().await;
22397        shared.reconcile_managed_record(
22398            &record_id,
22399            &address,
22400            &ControlEvent {
22401                sequence: 1,
22402                command_id: None,
22403                instance_id: address.session.instance_id,
22404                generation: address.session.generation,
22405                event: ControlEventKind::Removed,
22406            },
22407            false,
22408            false,
22409        );
22410        let remove = shared.remove_session(&address);
22411        let drive_runtime = async {
22412            runtime.tick().await;
22413        };
22414        let (removed, ()) = tokio::join!(remove, drive_runtime);
22415        removed.unwrap();
22416
22417        assert!(!materialization_root.exists());
22418        assert!(!Path::new(&created.path).exists());
22419        assert_eq!(
22420            shared
22421                .managed_worktrees
22422                .lock()
22423                .unwrap_or_else(|poisoned| poisoned.into_inner())
22424                .get(&lease.lease_id)
22425                .unwrap()
22426                .state,
22427            ManagedWorktreeLeaseState::Removed,
22428        );
22429        let renamed = shared
22430            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22431            .unwrap();
22432        assert_eq!(renamed.record_id, record_id);
22433        assert_eq!(renamed.display_name, "raw managed inventory");
22434        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22435        assert!(renamed.active_session.is_none());
22436
22437        drop(shared);
22438        drop(runtime);
22439        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22440        std::fs::remove_dir_all(git_root).unwrap();
22441    }
22442
22443    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22444        let root = std::env::current_dir().unwrap()
22445            .join("managed-test-target")
22446            .to_string_lossy()
22447            .into_owned();
22448        let lease = ManagedWorktreeLeaseRecord {
22449            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22450            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22451            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22452            profile_id: WorktreeProfileId::new("default").unwrap(),
22453            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22454            target_root: root.clone(),
22455            branch: "gate4agent/mw-test".to_owned(),
22456            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22457            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22458            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22459            state: ManagedWorktreeLeaseState::Ready,
22460            session_holders: Vec::new(),
22461            record_holders: Vec::new(),
22462            cleanup_failure: None,
22463            created_at_unix_ms: 1,
22464            updated_at_unix_ms: 1,
22465        };
22466        shared.workspaces.write()
22467            .unwrap_or_else(|poisoned| poisoned.into_inner())
22468            .insert(lease.workspace_id.clone(), root);
22469        *shared.managed_worktrees.lock()
22470            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22471            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22472        lease
22473    }
22474
22475    #[cfg(feature = "fixture")]
22476    #[test]
22477    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22478        let mut shared = terminal_test_shared();
22479        shared.fixture_semantic_hook_policy = true;
22480        let lease = install_managed_lease(&shared);
22481        let address = SessionAddress {
22482            workspace_id: lease.workspace_id.clone(),
22483            session: SessionKey {
22484                instance_id: AgentInstanceId(206),
22485                generation: SessionGeneration(1),
22486            },
22487        };
22488        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22489        let effective_runtime_policy =
22490            shared.effective_spawn_runtime_policy(pre_admission_policy);
22491        assert_ne!(effective_runtime_policy, pre_admission_policy);
22492        assert!(effective_runtime_policy.provider_session_identity);
22493
22494        let record_id = shared
22495            .bind_spawn_session(
22496                &address,
22497                agent("claude"),
22498                SessionMode::Pty,
22499                effective_runtime_policy,
22500                SpawnRecordPolicy::Always,
22501                None,
22502            )
22503            .unwrap()
22504            .unwrap();
22505        assert_eq!(
22506            shared.record(&record_id).unwrap().state,
22507            ManagedSessionState::IdentityPending,
22508        );
22509        let (record_holder, raw_inventory_record_id) = {
22510            let mut bindings = shared
22511                .session_bindings
22512                .lock()
22513                .unwrap_or_else(|poisoned| poisoned.into_inner());
22514            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22515            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22516            managed_spawn_record_ownership(binding, effective_runtime_policy)
22517        };
22518        assert_eq!(record_holder.as_ref(), Some(&record_id));
22519        assert!(raw_inventory_record_id.is_none());
22520        let snapshot = shared
22521            .managed_worktrees
22522            .lock()
22523            .unwrap_or_else(|poisoned| poisoned.into_inner())
22524            .bind_session(
22525                &lease.lease_id,
22526                ManagedWorktreeSessionHolder {
22527                    incarnation_id: shared.incarnation_id,
22528                    instance_id: address.session.instance_id,
22529                    generation: address.session.generation,
22530                },
22531                record_holder,
22532                unix_time_ms(),
22533            )
22534            .unwrap();
22535        assert_eq!(snapshot.managed_record_count, 1);
22536        assert_eq!(
22537            shared
22538                .managed_worktrees
22539                .lock()
22540                .unwrap_or_else(|poisoned| poisoned.into_inner())
22541                .get(&lease.lease_id)
22542                .unwrap()
22543                .record_holders,
22544            vec![record_id.clone()],
22545        );
22546
22547        let identity = ProviderSessionIdentity {
22548            key: ProviderSessionKey::SessionId,
22549            id: "monitoring-fixture-managed-session".to_owned(),
22550            transcript_path: None,
22551        };
22552        shared.publish_control(ControlEvent {
22553            sequence: 1,
22554            command_id: None,
22555            instance_id: address.session.instance_id,
22556            generation: address.session.generation,
22557            event: ControlEventKind::ProviderEvent {
22558                sequence: 1,
22559                source: gate4agent_types::ProviderSource {
22560                    family: AdapterFamily::Hook,
22561                    binding: AdapterBinding::new(
22562                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22563                        "fixture/v1",
22564                        gate4agent_types::AdapterVerification::SyntheticFixture,
22565                    )
22566                    .unwrap(),
22567                },
22568                source_sequence: 1,
22569                event: ProviderEvent::SessionIdentityObserved {
22570                    identity: identity.clone(),
22571                },
22572            },
22573        });
22574
22575        let live = shared.record(&record_id).unwrap();
22576        assert_eq!(live.state, ManagedSessionState::Live);
22577        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22578        assert_eq!(live.active_session.as_ref(), Some(&address));
22579        assert_eq!(
22580            shared
22581                .managed_worktrees
22582                .lock()
22583                .unwrap_or_else(|poisoned| poisoned.into_inner())
22584                .get(&lease.lease_id)
22585                .unwrap()
22586                .record_holders,
22587            vec![record_id],
22588        );
22589    }
22590
22591    fn managed_v2_test_shared(
22592        allocation_root: &Path,
22593        state_path: Option<PathBuf>,
22594        profile_revision: &str,
22595    ) -> NodeShared {
22596        let profile = ManagedWorktreeProfile::new(
22597            WorktreeProfileId::new("default").unwrap(),
22598            WorktreeProfileRevision::new(profile_revision).unwrap(),
22599            allocation_root,
22600            "gate4agent",
22601            "HEAD",
22602            ManagedWorktreeRetention::RemoveWhenReleased,
22603        )
22604        .unwrap();
22605        let workspace = WorkspaceConfig::new(
22606            WorkspaceId::new("primary").unwrap(),
22607            std::env::current_dir().unwrap(),
22608        )
22609        .unwrap()
22610        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22611        .with_managed_worktree_profile(profile)
22612        .unwrap();
22613        let catalog = active_registry().unwrap();
22614        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22615        let mut shared = NodeShared::new(
22616            handle,
22617            "fixture-token".to_owned(),
22618            NodeId::new("node-terminal-test").unwrap(),
22619            vec![workspace],
22620            vec![agent("claude")],
22621        );
22622        shared.state_path = state_path;
22623        shared
22624    }
22625
22626    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22627        let mut spawn_spec = spawn_spec_fixture();
22628        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22629        ManagedWorktreeSpawnRequestV2 {
22630            spawn_spec,
22631            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22632            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22633        }
22634    }
22635
22636    #[test]
22637    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22638        let shared = terminal_test_shared();
22639        let mut lease = install_managed_lease(&shared);
22640        let session = SessionAddress {
22641            workspace_id: lease.workspace_id.clone(),
22642            session: SessionKey {
22643                instance_id: AgentInstanceId(73),
22644                generation: SessionGeneration(4),
22645            },
22646        };
22647        lease.state = ManagedWorktreeLeaseState::InUse;
22648        lease.session_holders.push(ManagedWorktreeSessionHolder {
22649            incarnation_id: shared.incarnation_id,
22650            instance_id: session.session.instance_id,
22651            generation: session.session.generation,
22652        });
22653        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22654        assert!(resolved.target.worktree_id.is_none());
22655
22656        let receipt = managed_spawn_receipt(
22657            &resolved,
22658            shared.incarnation_id,
22659            session.clone(),
22660            lease.snapshot(),
22661            None,
22662            None,
22663            None,
22664        );
22665        assert!(resolved.target.worktree_id.is_none());
22666        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22667        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22668        assert_eq!(receipt.spawn.session, session);
22669        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22670        let encoded = serde_json::to_vec(&receipt).unwrap();
22671        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22672        assert_eq!(decoded, receipt);
22673    }
22674
22675    #[tokio::test]
22676    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22677        let root = temporary_workspace_root("managed-v2-revision");
22678        let allocation = root.join("allocation");
22679        std::fs::create_dir_all(&allocation).unwrap();
22680        let shared = managed_v2_test_shared(
22681            &allocation,
22682            Some(root.join("node-state.json")),
22683            "v1",
22684        );
22685        let mut request = managed_v2_request("managed-v2-revision");
22686        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22687
22688        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22689
22690        assert_eq!(
22691            failure.code,
22692            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22693        );
22694        assert!(shared
22695            .managed_worktrees
22696            .lock()
22697            .unwrap_or_else(|poisoned| poisoned.into_inner())
22698            .records()
22699            .is_empty());
22700        assert!(shared
22701            .managed_spawn_replays
22702            .lock()
22703            .unwrap_or_else(|poisoned| poisoned.into_inner())
22704            .is_empty());
22705        std::fs::remove_dir_all(root).unwrap();
22706    }
22707
22708    #[test]
22709    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22710        let root = temporary_workspace_root("managed-v2-concurrent");
22711        let allocation = root.join("allocation");
22712        std::fs::create_dir_all(&allocation).unwrap();
22713        let shared = Arc::new(managed_v2_test_shared(
22714            &allocation,
22715            Some(root.join("node-state.json")),
22716            "v1",
22717        ));
22718        let request = managed_v2_request("managed-v2-concurrent");
22719        let barrier = Arc::new(std::sync::Barrier::new(3));
22720        let mut joins = Vec::new();
22721        for _ in 0..2 {
22722            let shared = Arc::clone(&shared);
22723            let request = request.clone();
22724            let barrier = Arc::clone(&barrier);
22725            joins.push(std::thread::spawn(move || {
22726                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22727                barrier.wait();
22728                shared.acquire_durable_managed_spawn_v2(&request, digest)
22729            }));
22730        }
22731        barrier.wait();
22732        let results = joins
22733            .into_iter()
22734            .map(|join| join.join().unwrap())
22735            .collect::<Vec<_>>();
22736        assert_eq!(
22737            results
22738                .iter()
22739                .filter(|result| {
22740                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22741                })
22742                .count(),
22743            1,
22744        );
22745        assert_eq!(
22746            results
22747                .iter()
22748                .filter(|result| {
22749                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22750                })
22751                .count(),
22752            1,
22753        );
22754        assert_eq!(
22755            shared
22756                .managed_spawn_replays
22757                .lock()
22758                .unwrap_or_else(|poisoned| poisoned.into_inner())
22759                .len(),
22760            1,
22761        );
22762        assert!(shared
22763            .managed_worktrees
22764            .lock()
22765            .unwrap_or_else(|poisoned| poisoned.into_inner())
22766            .records()
22767            .is_empty());
22768        drop(shared);
22769        std::fs::remove_dir_all(root).unwrap();
22770    }
22771
22772    #[tokio::test]
22773    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22774        let root = temporary_workspace_root("managed-v2-linked-pending");
22775        let allocation = root.join("allocation");
22776        std::fs::create_dir_all(&allocation).unwrap();
22777        let state_path = root.join("node-state.json");
22778        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22779        let lease = install_managed_lease(&shared);
22780        let request = managed_v2_request("managed-v2-linked-pending");
22781        assert!(matches!(
22782            shared
22783                .acquire_durable_managed_spawn_v2(
22784                    &request,
22785                    managed_spawn_request_digest_v2(&request).unwrap(),
22786                )
22787                .unwrap(),
22788            DurableManagedSpawnReplayDecision::Reserved,
22789        ));
22790        {
22791            let _transaction = shared
22792                .state_transaction
22793                .lock()
22794                .unwrap_or_else(|poisoned| poisoned.into_inner());
22795            shared
22796                .link_durable_managed_spawn_v2_lease_locked(
22797                    &request.spawn_spec.idempotency_key,
22798                    &lease.lease_id,
22799                )
22800                .unwrap();
22801            shared.persist_state_locked().unwrap();
22802        }
22803        drop(shared);
22804
22805        let mut loaded = session_registry::load(
22806            Some(&state_path),
22807            &NodeId::new("node-terminal-test").unwrap(),
22808        )
22809        .unwrap();
22810        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22811        *reopened
22812            .managed_worktrees
22813            .lock()
22814            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22815            ManagedWorktreeRegistry::from_records(
22816                std::mem::take(&mut loaded.managed_worktrees),
22817                std::mem::take(&mut loaded.managed_worktree_tombstones),
22818            )
22819            .unwrap();
22820        *reopened
22821            .managed_spawn_replays
22822            .lock()
22823            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22824            .managed_spawn_replays
22825            .into_iter()
22826            .map(|record| (record.idempotency_key.clone(), record))
22827            .collect();
22828        let lease_count = reopened
22829            .managed_worktrees
22830            .lock()
22831            .unwrap_or_else(|poisoned| poisoned.into_inner())
22832            .records()
22833            .len();
22834
22835        let failure = reopened
22836            .spawn_managed_worktree_v2(request)
22837            .await
22838            .unwrap_err();
22839
22840        assert_eq!(
22841            failure.code,
22842            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22843        );
22844        assert_eq!(
22845            reopened
22846                .managed_worktrees
22847                .lock()
22848                .unwrap_or_else(|poisoned| poisoned.into_inner())
22849                .records()
22850                .len(),
22851            lease_count,
22852        );
22853        std::fs::remove_dir_all(root).unwrap();
22854    }
22855
22856    #[tokio::test]
22857    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22858        let root = temporary_workspace_root("managed-v2-reopen");
22859        let allocation = root.join("allocation");
22860        std::fs::create_dir_all(&allocation).unwrap();
22861        let state_path = root.join("node-state.json");
22862        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22863        let lease = install_managed_lease(&shared);
22864        let request = managed_v2_request("managed-v2-reopen");
22865        let session = SessionAddress {
22866            workspace_id: lease.workspace_id.clone(),
22867            session: SessionKey {
22868                instance_id: AgentInstanceId(91),
22869                generation: SessionGeneration(1),
22870            },
22871        };
22872        let snapshot = {
22873            let mut registry = shared
22874                .managed_worktrees
22875                .lock()
22876                .unwrap_or_else(|poisoned| poisoned.into_inner());
22877            let current = registry.get_mut(&lease.lease_id).unwrap();
22878            current.state = ManagedWorktreeLeaseState::InUse;
22879            current.session_holders.push(ManagedWorktreeSessionHolder {
22880                incarnation_id: shared.incarnation_id,
22881                instance_id: session.session.instance_id,
22882                generation: session.session.generation,
22883            });
22884            current.snapshot()
22885        };
22886        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22887        let receipt = managed_spawn_receipt(
22888            &resolved,
22889            shared.incarnation_id,
22890            session,
22891            snapshot,
22892            None,
22893            None,
22894            None,
22895        );
22896        assert!(matches!(
22897            shared
22898                .acquire_durable_managed_spawn_v2(
22899                    &request,
22900                    managed_spawn_request_digest_v2(&request).unwrap(),
22901                )
22902                .unwrap(),
22903            DurableManagedSpawnReplayDecision::Reserved,
22904        ));
22905        {
22906            let _transaction = shared
22907                .state_transaction
22908                .lock()
22909                .unwrap_or_else(|poisoned| poisoned.into_inner());
22910            shared
22911                .link_durable_managed_spawn_v2_lease_locked(
22912                    &request.spawn_spec.idempotency_key,
22913                    &lease.lease_id,
22914                )
22915                .unwrap();
22916            shared.persist_state_locked().unwrap();
22917        }
22918        shared
22919            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22920            .unwrap();
22921        let legacy_same_key = ManagedWorktreeSpawnRequest {
22922            spawn_spec: request.spawn_spec.clone(),
22923            worktree_profile_id: request.worktree_profile_id.clone(),
22924        };
22925        shared.remember_managed_spawn_attempt(
22926            Some(&request.spawn_spec.idempotency_key),
22927            legacy_same_key.clone(),
22928            Ok(receipt.clone()),
22929            Instant::now(),
22930        );
22931        assert!(shared
22932            .replay_managed_spawn(&legacy_same_key, Instant::now())
22933            .unwrap()
22934            .is_none());
22935        drop(shared);
22936
22937        let mut loaded = session_registry::load(
22938            Some(&state_path),
22939            &NodeId::new("node-terminal-test").unwrap(),
22940        )
22941        .unwrap();
22942        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22943        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22944        *reopened
22945            .managed_worktrees
22946            .lock()
22947            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22948            ManagedWorktreeRegistry::from_records(
22949                std::mem::take(&mut loaded.managed_worktrees),
22950                std::mem::take(&mut loaded.managed_worktree_tombstones),
22951            )
22952            .unwrap();
22953        *reopened
22954            .managed_spawn_replays
22955            .lock()
22956            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22957            .managed_spawn_replays
22958            .into_iter()
22959            .map(|record| (record.idempotency_key.clone(), record))
22960            .collect();
22961        let lease_count_before = reopened
22962            .managed_worktrees
22963            .lock()
22964            .unwrap_or_else(|poisoned| poisoned.into_inner())
22965            .records()
22966            .len();
22967
22968        assert_eq!(
22969            reopened
22970                .spawn_managed_worktree_v2(request.clone())
22971                .await
22972                .unwrap(),
22973            receipt,
22974        );
22975        assert_eq!(
22976            reopened
22977                .managed_worktrees
22978                .lock()
22979                .unwrap_or_else(|poisoned| poisoned.into_inner())
22980                .records()
22981                .len(),
22982            lease_count_before,
22983        );
22984        let mut changed = request;
22985        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22986        let failure = reopened
22987            .spawn_managed_worktree_v2(changed)
22988            .await
22989            .unwrap_err();
22990        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22991        assert_eq!(
22992            reopened
22993                .managed_worktrees
22994                .lock()
22995                .unwrap_or_else(|poisoned| poisoned.into_inner())
22996                .records()
22997                .len(),
22998            lease_count_before,
22999        );
23000        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23001        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23002        let failure = reopened
23003            .spawn_managed_worktree_v2(new_key)
23004            .await
23005            .unwrap_err();
23006        assert_eq!(
23007            failure.code,
23008            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23009        );
23010        assert_eq!(
23011            reopened
23012                .managed_worktrees
23013                .lock()
23014                .unwrap_or_else(|poisoned| poisoned.into_inner())
23015                .records()
23016                .len(),
23017            lease_count_before,
23018        );
23019        assert_eq!(
23020            reopened
23021                .managed_spawn_replays
23022                .lock()
23023                .unwrap_or_else(|poisoned| poisoned.into_inner())
23024                .len(),
23025            1,
23026        );
23027        std::fs::remove_dir_all(root).unwrap();
23028    }
23029
23030    #[test]
23031    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23032        let shared = terminal_test_shared();
23033        let request = ManagedWorktreeSpawnRequest {
23034            spawn_spec: spawn_spec_fixture(),
23035            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23036        };
23037        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23038        shared.remember_managed_spawn(
23039            request.clone(),
23040            Err(expected.clone()),
23041            Instant::now(),
23042        );
23043
23044        assert_eq!(
23045            shared
23046                .replay_managed_spawn(&request, Instant::now())
23047                .unwrap()
23048                .unwrap(),
23049            Err(expected),
23050        );
23051        assert!(shared
23052            .managed_spawn_replays
23053            .lock()
23054            .unwrap_or_else(|poisoned| poisoned.into_inner())
23055            .is_empty());
23056    }
23057
23058    #[tokio::test]
23059    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23060        let shared = terminal_test_shared();
23061        let lease = install_managed_lease(&shared);
23062        let raw = shared.spawn_session(
23063            lease.workspace_id.clone(),
23064            agent("claude"),
23065            SessionMode::Pty,
23066            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23067            None,
23068        ).await.unwrap_err();
23069        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23070        let mut spec = spawn_spec_fixture();
23071        spec.target.workspace_id = lease.workspace_id.clone();
23072        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23073        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23074        let mut selected = spawn_spec_fixture();
23075        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23076        selected.target.worktree_id = Some(lease.workspace_id);
23077        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23078        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23079        assert!(shared.handle.snapshot().sessions.is_empty());
23080    }
23081
23082    #[test]
23083    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23084        let shared = terminal_test_shared();
23085        let lease = install_managed_lease(&shared);
23086        let snapshot = snapshot_for_wire(
23087            &shared, true, true, true, false, true, true, false, false, true,
23088        );
23089        assert!(snapshot.managed_worktrees.is_empty());
23090        assert!(snapshot.workspaces.iter().all(|workspace| {
23091            workspace.managed_worktree_profiles.is_none()
23092        }));
23093        let mut reply = ResponseEnvelope {
23094            request_id: 1,
23095            result: Ok(NodeResponse::Resync {
23096                event_sequence: 1,
23097                oldest_available_sequence: 1,
23098                snapshot: shared.snapshot(),
23099                events: vec![NodeEventEnvelope {
23100                    sequence: 1,
23101                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23102                }],
23103            }),
23104        };
23105        project_response_without_managed_worktrees(&mut reply);
23106        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23107            panic!("projection changed the response kind");
23108        };
23109        assert!(snapshot.managed_worktrees.is_empty());
23110        assert!(events.is_empty());
23111        let mut git = git_snapshot_for_parser();
23112        git.branch = Some(lease.branch.clone());
23113        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23114            lease_id: lease.lease_id.clone(),
23115            source_workspace_id: lease.source_workspace_id.clone(),
23116            branch: lease.branch.clone(),
23117            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23118            active_session_count: 1,
23119            managed_record_count: 0,
23120        });
23121        let mut inspection_reply = ResponseEnvelope {
23122            request_id: 2,
23123            result: Ok(NodeResponse::WorkspaceInspected {
23124                inspection: WorkspaceInspection {
23125                    workspace_id: lease.workspace_id.clone(),
23126                    entries: Vec::new(),
23127                    tree_truncated: false,
23128                    git,
23129                    truncation: None,
23130                },
23131            }),
23132        };
23133        project_response_without_managed_worktrees(&mut inspection_reply);
23134        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23135            panic!("projection changed the inspection response kind");
23136        };
23137        assert!(inspection.git.managed_worktree.is_none());
23138        let failure = managed_git_worktree_failure(GitWorktreeError {
23139            kind: GitWorktreeErrorKind::Failed,
23140            message: r"git failed at C:\private\managed-target".to_owned(),
23141        });
23142        assert!(!failure.message.contains("private"));
23143        assert!(!failure.message.contains("managed-target"));
23144    }
23145
23146    #[tokio::test]
23147    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23148        let root = temporary_workspace_root("managed-restart");
23149        let source = root.join("source");
23150        let allocation = root.join("allocation");
23151        std::fs::create_dir_all(&source).unwrap();
23152        std::fs::create_dir_all(&allocation).unwrap();
23153        run_test_git(&source, &["init"]);
23154        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23155        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23156        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23157        run_test_git(&source, &["add", "seed.txt"]);
23158        run_test_git(&source, &["commit", "-m", "seed"]);
23159        let profile = ManagedWorktreeProfile::new(
23160            WorktreeProfileId::new("default").unwrap(),
23161            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23162            &allocation,
23163            "gate4agent",
23164            "HEAD",
23165            ManagedWorktreeRetention::Retain,
23166        ).unwrap();
23167        let workspace = WorkspaceConfig::new(
23168            WorkspaceId::new("primary").unwrap(),
23169            &source,
23170        ).unwrap()
23171            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23172            .with_managed_worktree_profile(profile.clone()).unwrap();
23173        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23174        let source_text = workspace.canonical_root().to_owned();
23175        let target_text = target.to_string_lossy().into_owned();
23176        let created = create_git_worktree(
23177            &source_text,
23178            &target_text,
23179            "gate4agent/mw-restart",
23180            Some("HEAD"),
23181        ).await.unwrap();
23182        let lease = ManagedWorktreeLeaseRecord {
23183            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23184            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23185            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23186            profile_id: profile.profile_id().clone(),
23187            profile_revision: profile.revision().clone(),
23188            target_root: created.path.clone(),
23189            branch: "gate4agent/mw-restart".to_owned(),
23190            base_commit: created.head.clone(),
23191            expected_head: None,
23192            retention: ManagedWorktreeRetention::Retain,
23193            state: ManagedWorktreeLeaseState::Allocating,
23194            session_holders: Vec::new(),
23195            record_holders: Vec::new(),
23196            cleanup_failure: None,
23197            created_at_unix_ms: 1,
23198            updated_at_unix_ms: 1,
23199        };
23200        let catalog = active_registry().unwrap();
23201        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23202        let shared = NodeShared::new_with_incarnation(
23203            handle,
23204            "fixture-token".to_owned(),
23205            NodeId::new("node-managed-restart").unwrap(),
23206            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23207            vec![workspace],
23208            vec![agent("claude")],
23209            ProviderRuntimeStatuses::default(),
23210            None,
23211            Vec::new(),
23212            Vec::new(),
23213            SpawnProfileRegistry::default(),
23214            None,
23215            None,
23216            None,
23217            None,
23218            Vec::new(),
23219            vec![lease.clone()],
23220            Vec::new(),
23221            Vec::new(),
23222            None,
23223            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23224        );
23225        shared.reconcile_managed_worktrees().await;
23226        let retained = shared.managed_worktrees.lock()
23227            .unwrap_or_else(|poisoned| poisoned.into_inner())
23228            .get(&lease.lease_id).unwrap().clone();
23229        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23230        assert!(shared.workspaces.read()
23231            .unwrap_or_else(|poisoned| poisoned.into_inner())
23232            .contains_key(&lease.workspace_id));
23233        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23234        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23235        assert!(!Path::new(&created.path).exists());
23236        std::fs::remove_dir_all(root).unwrap();
23237    }
23238
23239    fn run_test_git(root: &Path, arguments: &[&str]) {
23240        let output = std::process::Command::new("git")
23241            .arg("-C").arg(root).args(arguments).output().unwrap();
23242        assert!(
23243            output.status.success(),
23244            "git {:?} failed: {}",
23245            arguments,
23246            String::from_utf8_lossy(&output.stderr),
23247        );
23248    }
23249
23250    #[tokio::test]
23251    async fn spawn_spec_validation_fails_before_session_mutation() {
23252        let shared = terminal_test_shared();
23253        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23254            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23255        }));
23256        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23257            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23258        }));
23259        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23260            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23261        }));
23262
23263        let mut target_mismatch = spawn_spec_fixture();
23264        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23265        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23266        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23267
23268        let mut unknown_profile = spawn_spec_fixture();
23269        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23270        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23271        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23272        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23273
23274        let mut revision_mismatch = spawn_spec_fixture();
23275        revision_mismatch.idempotency_key =
23276            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23277        revision_mismatch.expected_profile_revision =
23278            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23279        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23280        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23281
23282        let mut unsupported_materializer = spawn_spec_fixture();
23283        unsupported_materializer.idempotency_key =
23284            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23285        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23286            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23287        };
23288        let failure = shared
23289            .spawn_from_spec(unsupported_materializer)
23290            .await
23291            .unwrap_err();
23292        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23293
23294        let failure = shared
23295            .spawn_session_with_deadline(
23296                WorkspaceId::new("primary").unwrap(),
23297                agent("claude"),
23298                SessionMode::Pty,
23299                gate4agent_types::TerminalSize {
23300                    rows: 24,
23301                    columns: 80,
23302                },
23303                None,
23304                None,
23305                None,
23306                None,
23307                None,
23308                None,
23309                SpawnRecordPolicy::ProviderIdentityOnly,
23310                Some(Instant::now()),
23311                &[],
23312                None,
23313                ApprovalLevel::default(),
23314                None,
23315                None,
23316            )
23317            .await
23318            .unwrap_err();
23319        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23320        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23321        assert!(shared.handle.snapshot().sessions.is_empty());
23322    }
23323
23324    #[test]
23325    fn worktree_selection_requires_its_separate_negotiated_capability() {
23326        let mut spec = spawn_spec_fixture();
23327        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23328        let request = NodeRequest::SpawnSpec { spec };
23329        let spawn_spec_capability =
23330            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23331        assert!(request_uses_unnegotiated_capability(
23332            &request,
23333            &[spawn_spec_capability.clone()],
23334        ));
23335        let worktree_capability =
23336            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23337        assert!(request_uses_unnegotiated_capability(
23338            &request,
23339            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23340        ));
23341        let profile_revision_capability =
23342            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23343        assert!(request_uses_unnegotiated_capability(
23344            &request,
23345            &[
23346                spawn_spec_capability.clone(),
23347                worktree_capability.clone(),
23348                profile_revision_capability.clone(),
23349            ],
23350        ));
23351        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23352        // `Inherit`, not `Clear` — the profile's own defaults might still
23353        // materialize a bundle or a context pack, so both capabilities stay
23354        // conservatively required until negotiated (see
23355        // `requires_session_bundle_materialization_capability` /
23356        // `requires_history_context_pack_capability`).
23357        let bundle_materialization_capability =
23358            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23359        assert!(request_uses_unnegotiated_capability(
23360            &request,
23361            &[
23362                spawn_spec_capability.clone(),
23363                worktree_capability.clone(),
23364                profile_revision_capability.clone(),
23365                bundle_materialization_capability.clone(),
23366            ],
23367        ));
23368        let history_context_pack_capability =
23369            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23370        assert!(!request_uses_unnegotiated_capability(
23371            &request,
23372            &[
23373                spawn_spec_capability,
23374                worktree_capability,
23375                profile_revision_capability,
23376                bundle_materialization_capability,
23377                history_context_pack_capability,
23378            ],
23379        ));
23380    }
23381
23382    #[tokio::test]
23383    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23384        let mut shared = terminal_test_shared();
23385        let spec = spawn_spec_fixture();
23386        let receipt = shared
23387            .resolve_spawn_spec(&spec)
23388            .unwrap()
23389            .receipt(shared.incarnation_id, terminal_address(1));
23390        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23391
23392        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23393        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23394        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23395
23396        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23397
23398        let mut revision_conflicting = spec.clone();
23399        revision_conflicting.expected_profile_revision =
23400            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23401        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23402        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23403
23404        let mut stale_cache_miss = spec.clone();
23405        stale_cache_miss.idempotency_key =
23406            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23407        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23408        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23409
23410        let mut conflicting = spec;
23411        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23412        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23413        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23414        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23415        assert!(shared.handle.snapshot().sessions.is_empty());
23416    }
23417
23418    #[tokio::test]
23419    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23420        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23421        let mut spec = spawn_spec_fixture();
23422        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23423        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23424        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23425
23426        let failure = shared
23427            .create_worktree(
23428                WorkspaceId::new("primary").unwrap(),
23429                WorkspaceId::new("new-worktree").unwrap(),
23430                "not-an-absolute-path".to_owned(),
23431                "invalid branch".to_owned(),
23432                None,
23433            )
23434            .await
23435            .unwrap_err();
23436        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23437
23438        let failure = shared
23439            .remove_worktree(
23440                WorkspaceId::new("primary").unwrap(),
23441                "not-an-absolute-path".to_owned(),
23442            )
23443            .await
23444            .unwrap_err();
23445        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23446        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23447        assert!(shared.handle.snapshot().sessions.is_empty());
23448    }
23449
23450    #[tokio::test]
23451    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23452        let root = temporary_workspace_root("manual-worktree-compensation");
23453        let source = root.join("source");
23454        let target = root.join("target");
23455        let invalid_state_parent = root.join("state-parent-is-a-file");
23456        let invalid_state_path = invalid_state_parent.join("state.json");
23457        std::fs::create_dir_all(&source).unwrap();
23458        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23459        run_test_git(&source, &["init"]);
23460        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23461        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23462        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23463        run_test_git(&source, &["add", "seed.txt"]);
23464        run_test_git(&source, &["commit", "-m", "seed"]);
23465
23466        let catalog = active_registry().unwrap();
23467        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23468        let workspace = WorkspaceConfig::new(
23469            WorkspaceId::new("primary").unwrap(),
23470            &source,
23471        ).unwrap();
23472        let mut shared = NodeShared::new(
23473            handle,
23474            "fixture-token".to_owned(),
23475            NodeId::new("node-worktree-compensation").unwrap(),
23476            vec![workspace],
23477            vec![agent("claude")],
23478        );
23479        shared.state_path = Some(invalid_state_path);
23480
23481        let error = shared.create_worktree(
23482            WorkspaceId::new("primary").unwrap(),
23483            WorkspaceId::new("compensated").unwrap(),
23484            target.to_string_lossy().into_owned(),
23485            "gate4agent/compensated".to_owned(),
23486            Some("HEAD".to_owned()),
23487        ).await.unwrap_err();
23488
23489        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23490        assert!(!target.exists());
23491        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23492            .iter()
23493            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23494        std::fs::remove_dir_all(root).unwrap();
23495    }
23496
23497    #[test]
23498    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23499        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23500            NativeGitWorktreeSnapshot {
23501                path: path.to_owned(),
23502                head: "0123456789abcdef".to_owned(),
23503                branch: Some("refs/heads/test".to_owned()),
23504                is_bare,
23505                is_main,
23506                locked: false,
23507                lock_reason: None,
23508                prunable: false,
23509                prunable_reason: None,
23510                workspace_id: None,
23511            }
23512        }
23513
23514        let source = r"C:\repo";
23515        let selected = r"C:\repo-worktree";
23516        let listed = vec![
23517            worktree(source, true, false),
23518            worktree(selected, false, false),
23519        ];
23520        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23521
23522        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23523        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23524
23525        let bare = vec![
23526            worktree(source, true, false),
23527            worktree(selected, false, true),
23528        ];
23529        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23530        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23531
23532        let foreign_failure =
23533            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23534        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23535    }
23536
23537    fn terminal_frame(sequence: u64) -> TerminalFrame {
23538        TerminalFrame {
23539            sequence,
23540            size: gate4agent_types::TerminalSize {
23541                rows: 24,
23542                columns: 80,
23543            },
23544            cursor_row: 2,
23545            cursor_column: 3,
23546            contents: format!("frame-{sequence}"),
23547            formatted: vec![1, 2, 3],
23548            scrollback_formatted: Vec::new(),
23549            alternate_screen: false,
23550            mouse_protocol_enabled: false,
23551            mouse_protocol_encoding: Default::default(),
23552            produced_at_unix_ms: 0,
23553            screen_state: gate4agent_types::PtyScreenState::default(),
23554            bracketed_paste: None,
23555        }
23556    }
23557
23558    #[test]
23559    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23560        let shared = terminal_test_shared();
23561        let mut receiver = shared.terminal_event_tx.subscribe();
23562        let original = terminal_address(1);
23563
23564        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23565        let first = receiver.try_recv().unwrap();
23566        assert_eq!(first.len(), 1);
23567        assert!(matches!(
23568            &first[0].event,
23569            NodeEvent::TerminalFrame { address, frame }
23570                if address == &original && frame.sequence == 7
23571        ));
23572
23573        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23574        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23575        assert!(matches!(
23576            receiver.try_recv(),
23577            Err(broadcast::error::TryRecvError::Empty)
23578        ));
23579
23580        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23581        let advanced = receiver.try_recv().unwrap();
23582        assert_eq!(advanced[0].sequence, 2);
23583
23584        let resumed = terminal_address(2);
23585        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23586        let reset = receiver.try_recv().unwrap();
23587        assert_eq!(reset[0].sequence, 3);
23588        assert!(matches!(
23589            &reset[0].event,
23590            NodeEvent::TerminalFrame { address, frame }
23591                if address == &resumed && frame.sequence == 1
23592        ));
23593        shared.clear_terminal_frame_watermark(&resumed);
23594        assert!(!shared
23595            .terminal_frame_watermarks
23596            .lock()
23597            .unwrap_or_else(|poisoned| poisoned.into_inner())
23598            .contains_key(&resumed.session.instance_id));
23599    }
23600
23601    #[test]
23602    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23603        let shared = terminal_test_shared();
23604        let mut receiver = shared.terminal_event_tx.subscribe();
23605        let address = terminal_address(1);
23606        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23607
23608        for sequence in 1..=count {
23609            shared.publish_terminal_frame_candidates(vec![(
23610                address.clone(),
23611                terminal_frame(sequence),
23612            )]);
23613        }
23614
23615        let history = shared
23616            .history
23617            .lock()
23618            .unwrap_or_else(|poisoned| poisoned.into_inner());
23619        assert!(history.events.is_empty());
23620        assert_eq!(history.last_sequence, count);
23621        assert_eq!(history.replay_floor_sequence, 1);
23622        drop(history);
23623        let NodeResponse::Resync {
23624            event_sequence,
23625            oldest_available_sequence,
23626            events,
23627            ..
23628        } = shared.resync(0) else {
23629            unreachable!("resync helper returned another response")
23630        };
23631        assert_eq!(event_sequence, count);
23632        assert_eq!(oldest_available_sequence, 1);
23633        assert!(events.is_empty());
23634        assert!(matches!(
23635            resync_required_event(&shared).event,
23636            NodeEvent::ResyncRequired {
23637                oldest_available_sequence: 1,
23638            }
23639        ));
23640        assert!(matches!(
23641            receiver.try_recv(),
23642            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23643        ));
23644        let latest = receiver.try_recv().unwrap();
23645        assert_eq!(latest.len(), 1);
23646        assert_eq!(latest[0].sequence, count);
23647    }
23648
23649    #[test]
23650    fn real_durable_eviction_advances_authoritative_replay_floor() {
23651        let shared = terminal_test_shared();
23652        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23653            shared.publish(NodeEvent::ControllerChanged { controller: None });
23654        }
23655
23656        let NodeResponse::Resync {
23657            event_sequence,
23658            oldest_available_sequence,
23659            events,
23660            ..
23661        } = shared.resync(0) else {
23662            unreachable!("resync helper returned another response")
23663        };
23664        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23665        assert_eq!(oldest_available_sequence, 2);
23666        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23667        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23668        assert!(matches!(
23669            resync_required_event(&shared).event,
23670            NodeEvent::ResyncRequired {
23671                oldest_available_sequence: 2,
23672            }
23673        ));
23674    }
23675
23676    #[test]
23677    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23678        let shared = terminal_test_shared();
23679        let without_capability = terminal_event_subscription(&shared, false);
23680        let with_capability = terminal_event_subscription(&shared, true);
23681        assert!(without_capability.is_none());
23682        assert!(with_capability.is_some());
23683        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23684            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23685        }));
23686    }
23687
23688    #[test]
23689    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23690        let mut pending = Vec::new();
23691        let mut event_sequence = 0_u64;
23692        for frame_sequence in 1..=4 {
23693            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23694                event_sequence += 1;
23695                let mut address = terminal_address(1);
23696                address.session.instance_id = AgentInstanceId(instance);
23697                queue_connection_event(
23698                    &mut pending,
23699                    NodeEventEnvelope {
23700                        sequence: event_sequence,
23701                        event: NodeEvent::TerminalFrame {
23702                            address,
23703                            frame: terminal_frame(frame_sequence),
23704                        },
23705                    },
23706                    0,
23707                );
23708            }
23709        }
23710        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23711        pending.sort_unstable_by_key(|event| event.sequence);
23712
23713        let mut uninterrupted = pending.clone();
23714        let first_burst = connection_event_burst_len(uninterrupted.len());
23715        uninterrupted.drain(..first_burst);
23716        let second_burst = connection_event_burst_len(uninterrupted.len());
23717        uninterrupted.drain(..second_burst);
23718        assert!(uninterrupted.is_empty());
23719
23720        let (frame_tx, mut frame_rx) =
23721            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23722        frame_tx
23723            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23724                request_id: 77,
23725                request: NodeRequest::Snapshot,
23726            })))
23727            .unwrap();
23728        let burst_len = connection_event_burst_len(pending.len());
23729        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23730        pending.drain(..burst_len);
23731        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23732        assert!(matches!(
23733            frame_rx.try_recv(),
23734            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23735                request_id: 77,
23736                request: NodeRequest::Snapshot,
23737            })))
23738        ));
23739    }
23740
23741    #[test]
23742    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23743        let mut pending = Vec::new();
23744        for sequence in [7_u64, 8] {
23745            let mut address = terminal_address(1);
23746            address.session.instance_id = AgentInstanceId(sequence);
23747            queue_connection_event(
23748                &mut pending,
23749                NodeEventEnvelope {
23750                    sequence,
23751                    event: NodeEvent::TerminalFrame {
23752                        address,
23753                        frame: terminal_frame(sequence),
23754                    },
23755                },
23756                0,
23757            );
23758        }
23759        let mut discard_through = 0;
23760        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23761        assert!(pending.is_empty());
23762
23763        let replayed = NodeEventEnvelope {
23764            sequence: 8,
23765            event: NodeEvent::TerminalFrame {
23766                address: terminal_address(1),
23767                frame: terminal_frame(8),
23768            },
23769        };
23770        let advanced = NodeEventEnvelope {
23771            sequence: 9,
23772            event: NodeEvent::TerminalFrame {
23773                address: terminal_address(2),
23774                frame: terminal_frame(1),
23775            },
23776        };
23777        queue_connection_event_batch(
23778            &mut pending,
23779            &[replayed, advanced.clone()],
23780            discard_through,
23781        );
23782        assert_eq!(pending, vec![advanced]);
23783    }
23784
23785    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23786        ManagedSessionRecord {
23787            record_id: SessionRecordId::new(record_id).unwrap(),
23788            display_name: format!("{provider} record"),
23789            provider: agent(provider),
23790            mode: SessionMode::Pty,
23791            state: ManagedSessionState::Unavailable,
23792            workspace_id: WorkspaceId::new("primary").unwrap(),
23793            canonical_root: opaque_windows_path(
23794                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23795            ),
23796            provider_session: None,
23797            active_session: None,
23798            environment_profile: None,
23799            bundle: None,
23800            context_id: None,
23801            context: None,
23802            exported_context: None,
23803            task_binding: None,
23804            created_at_unix_ms: 1,
23805            updated_at_unix_ms: 1,
23806            last_error: None,
23807        }
23808    }
23809
23810    fn retention_test_record(
23811        workspace_id: &str,
23812        record_id: &str,
23813        state: ManagedSessionState,
23814        updated_at_unix_ms: u64,
23815    ) -> ManagedSessionRecord {
23816        ManagedSessionRecord {
23817            record_id: SessionRecordId::new(record_id).unwrap(),
23818            display_name: format!("{record_id} display"),
23819            provider: agent("claude"),
23820            mode: SessionMode::Pty,
23821            state,
23822            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23823            canonical_root: opaque_windows_path(
23824                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23825            ),
23826            provider_session: None,
23827            active_session: None,
23828            environment_profile: None,
23829            bundle: None,
23830            context_id: None,
23831            context: None,
23832            exported_context: None,
23833            task_binding: None,
23834            created_at_unix_ms: updated_at_unix_ms,
23835            updated_at_unix_ms,
23836            last_error: None,
23837        }
23838    }
23839
23840    /// Two workspaces of three `Unavailable` records each, staggered by
23841    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23842    /// `workspace-a` (all as old as that workspace's oldest real record --
23843    /// if the sweep's state gate ever leaked, these would be the first
23844    /// thing it wrongly retired). Ages, oldest to newest:
23845    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23846    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23847    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23848        vec![
23849            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23850            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23851            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23852            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23853            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23854            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23855            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23856            retention_test_record(
23857                "workspace-a",
23858                "decoy-pending",
23859                ManagedSessionState::IdentityPending,
23860                now - 500_000,
23861            ),
23862            retention_test_record(
23863                "workspace-a",
23864                "decoy-dormant",
23865                ManagedSessionState::Dormant,
23866                now - 500_000,
23867            ),
23868        ]
23869    }
23870
23871    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23872        let root = temporary_workspace_root("session-record-retention");
23873        std::fs::create_dir_all(&root).unwrap();
23874        let catalog = active_registry().unwrap();
23875        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23876        let workspace_a =
23877            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23878        let workspace_b =
23879            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23880        let mut shared = NodeShared::new(
23881            handle,
23882            "fixture-token".to_owned(),
23883            NodeId::new("node-retention-test").unwrap(),
23884            vec![workspace_a, workspace_b],
23885            vec![agent("claude")],
23886        );
23887        shared.state_path = Some(root.join("node-state-v10.json"));
23888        let now = unix_time_ms();
23889        for record in retention_fixture_records(now) {
23890            shared.insert_record(record).unwrap();
23891        }
23892        shared
23893            .persist_state_locked()
23894            .expect("fixture-only, empty-history persist against a fresh temp state path");
23895        (shared, runtime, root, now)
23896    }
23897
23898    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23899        shared
23900            .history
23901            .lock()
23902            .unwrap_or_else(|poisoned| poisoned.into_inner())
23903            .events
23904            .iter()
23905            .filter_map(|envelope| match &envelope.event {
23906                NodeEvent::SessionRecordRemoved { record_id } => {
23907                    Some(record_id.as_str().to_owned())
23908                }
23909                _ => None,
23910            })
23911            .collect()
23912    }
23913
23914    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23915        shared
23916            .session_records
23917            .lock()
23918            .unwrap_or_else(|poisoned| poisoned.into_inner())
23919            .records
23920            .keys()
23921            .map(|record_id| record_id.as_str().to_owned())
23922            .collect()
23923    }
23924
23925    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23926        values.iter().map(|value| (*value).to_owned()).collect()
23927    }
23928
23929    #[tokio::test]
23930    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23931        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23932        shared.session_record_retention = SessionRecordRetentionConfig {
23933            age_ms: 0,
23934            keep_per_workspace: 1,
23935        };
23936        shared.retire_unavailable_session_records().await;
23937
23938        assert_eq!(
23939            retention_remaining_record_ids(&shared),
23940            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23941        );
23942        assert_eq!(
23943            retention_removed_record_ids(&shared),
23944            owned(&["a1", "a2", "b1", "b2"]),
23945        );
23946        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23947
23948        drop(shared);
23949        drop(runtime);
23950        std::fs::remove_dir_all(root).unwrap();
23951    }
23952
23953    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23954    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23955    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23956    /// 200s old against a 250s threshold) even though it fails the keep-N
23957    /// test too, proving retirement needs BOTH, not either.
23958    #[tokio::test]
23959    async fn session_record_retention_both_set_retires_only_the_intersection() {
23960        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23961        shared.session_record_retention = SessionRecordRetentionConfig {
23962            age_ms: 250_000,
23963            keep_per_workspace: 1,
23964        };
23965        shared.retire_unavailable_session_records().await;
23966
23967        assert_eq!(
23968            retention_remaining_record_ids(&shared),
23969            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23970        );
23971        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23972        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23973
23974        drop(shared);
23975        drop(runtime);
23976        std::fs::remove_dir_all(root).unwrap();
23977    }
23978
23979    #[tokio::test]
23980    async fn session_record_retention_both_disabled_is_a_no_op() {
23981        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23982        shared.session_record_retention = SessionRecordRetentionConfig::default();
23983        shared.retire_unavailable_session_records().await;
23984
23985        assert_eq!(
23986            retention_remaining_record_ids(&shared),
23987            owned(&[
23988                "a1", "a2", "a3", "b1", "b2", "b3",
23989                "decoy-live", "decoy-pending", "decoy-dormant",
23990            ]),
23991        );
23992        assert!(retention_removed_record_ids(&shared).is_empty());
23993        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23994
23995        drop(shared);
23996        drop(runtime);
23997        std::fs::remove_dir_all(root).unwrap();
23998    }
23999
24000    #[test]
24001    fn session_record_context_export_selects_one_exact_history_identity() {
24002        let candidates = vec![
24003            HistoryCandidateSummary {
24004                id: "candidate-other".to_owned(),
24005                session_id_hint: "provider-other".to_owned(),
24006                modified_at_unix_ms: Some(1),
24007            },
24008            HistoryCandidateSummary {
24009                id: "candidate-exact".to_owned(),
24010                session_id_hint: "provider-exact".to_owned(),
24011                modified_at_unix_ms: Some(2),
24012            },
24013        ];
24014        assert_eq!(
24015            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24016            "candidate-exact",
24017        );
24018    }
24019
24020    #[test]
24021    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24022        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24023            id: id.to_owned(),
24024            session_id_hint: hint.to_owned(),
24025            modified_at_unix_ms: None,
24026        };
24027        let missing = select_history_candidate(
24028            &[candidate("candidate-other", "provider-other")],
24029            Some("provider-exact"),
24030        )
24031        .unwrap_err();
24032        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24033        let ambiguous = select_history_candidate(
24034            &[
24035                candidate("candidate-a", "provider-exact"),
24036                candidate("candidate-b", "provider-exact"),
24037            ],
24038            Some("provider-exact"),
24039        )
24040        .unwrap_err();
24041        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24042    }
24043
24044    /// The PTY-sidecar-observation shape (no fleet provider today, but
24045    /// capability-keyed rather than vendor-locked) never carries a verified
24046    /// `provider_session`; its record-bound export instead falls back to
24047    /// "exactly one discovered candidate" (see
24048    /// `export_context_pack_for_session_record_inner`). This is the same
24049    /// ambiguity refusal as the identity-filtered path, just without an
24050    /// identity to filter by.
24051    #[test]
24052    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24053        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24054            id: id.to_owned(),
24055            session_id_hint: hint.to_owned(),
24056            modified_at_unix_ms: None,
24057        };
24058        assert_eq!(
24059            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24060                .unwrap(),
24061            "candidate-sole",
24062        );
24063        let missing = select_history_candidate(&[], None).unwrap_err();
24064        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24065        let ambiguous = select_history_candidate(
24066            &[
24067                candidate("candidate-a", "sidecar-session-a"),
24068                candidate("candidate-b", "sidecar-session-b"),
24069            ],
24070            None,
24071        )
24072        .unwrap_err();
24073        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24074    }
24075
24076    #[test]
24077    fn session_record_context_export_revalidation_rejects_record_change() {
24078        let session = terminal_address(1);
24079        let identity = ProviderSessionIdentity {
24080            key: ProviderSessionKey::SessionId,
24081            id: "provider-exact".to_owned(),
24082            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24083        };
24084        let mut expected = record("claude", "record-context-export");
24085        expected.state = ManagedSessionState::Live;
24086        expected.provider_session = Some(identity.clone());
24087        expected.active_session = Some(session.clone());
24088        let binding = SessionBinding {
24089            workspace_id: session.workspace_id.clone(),
24090            generation: session.session.generation,
24091            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24092            pending_resume: None,
24093            record_id: Some(expected.record_id.clone()),
24094            managed_worktree_lease_id: None,
24095            environment_profile: None,
24096            bundle: None,
24097            context: None,
24098            materialization_id: None,
24099        };
24100        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24101        assert!(session_record_context_export_binding_is_exact(
24102            &expected,
24103            &expected,
24104            Some(&identity),
24105            &session,
24106            &binding,
24107            &expected.provider,
24108            &current_root,
24109            false,
24110        ));
24111        let mut changed = expected.clone();
24112        changed.updated_at_unix_ms += 1;
24113        assert!(!session_record_context_export_binding_is_exact(
24114            &expected,
24115            &changed,
24116            Some(&identity),
24117            &session,
24118            &binding,
24119            &expected.provider,
24120            &current_root,
24121            false,
24122        ));
24123        // A same-instant timestamp bump alone (state and active_session
24124        // otherwise unchanged) is not the clean-detachment transition
24125        // either, even with the reactive flag on: only the exact Live ->
24126        // Dormant + active_session cleared shape qualifies.
24127        assert!(!session_record_context_export_binding_is_exact(
24128            &expected,
24129            &changed,
24130            Some(&identity),
24131            &session,
24132            &binding,
24133            &expected.provider,
24134            &current_root,
24135            true,
24136        ));
24137        let mut detached = expected.clone();
24138        detached.state = ManagedSessionState::Dormant;
24139        detached.active_session = None;
24140        detached.updated_at_unix_ms += 1;
24141        assert!(!session_record_context_export_binding_is_exact(
24142            &expected,
24143            &detached,
24144            Some(&identity),
24145            &session,
24146            &binding,
24147            &expected.provider,
24148            &current_root,
24149            false,
24150        ));
24151        assert!(session_record_context_export_binding_is_exact(
24152            &expected,
24153            &detached,
24154            Some(&identity),
24155            &session,
24156            &binding,
24157            &expected.provider,
24158            &current_root,
24159            true,
24160        ));
24161    }
24162
24163    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24164    /// for a record whose provider never carries a verified identity (the
24165    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24166    /// declares one today): `identity` is `None` throughout, and the
24167    /// exactness check falls back to comparing `provider_session` itself,
24168    /// which must still be `None` on both sides.
24169    #[test]
24170    fn session_record_context_export_binding_is_exact_without_identity() {
24171        let session = terminal_address(1);
24172        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24173        expected.state = ManagedSessionState::Live;
24174        expected.active_session = Some(session.clone());
24175        let binding = SessionBinding {
24176            workspace_id: session.workspace_id.clone(),
24177            generation: session.session.generation,
24178            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24179            pending_resume: None,
24180            record_id: Some(expected.record_id.clone()),
24181            managed_worktree_lease_id: None,
24182            environment_profile: None,
24183            bundle: None,
24184            context: None,
24185            materialization_id: None,
24186        };
24187        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24188        assert!(session_record_context_export_binding_is_exact(
24189            &expected,
24190            &expected,
24191            None,
24192            &session,
24193            &binding,
24194            &expected.provider,
24195            &current_root,
24196            false,
24197        ));
24198        // A record that gained an identity between the initial lookup and
24199        // this recheck no longer matches the `None` this shape expects to
24200        // keep seeing -- treated the same as any other mid-export drift.
24201        let mut drifted = expected.clone();
24202        drifted.provider_session = Some(ProviderSessionIdentity {
24203            key: ProviderSessionKey::SessionId,
24204            id: "unexpected-identity".to_owned(),
24205            transcript_path: None,
24206        });
24207        assert!(!session_record_context_export_binding_is_exact(
24208            &expected,
24209            &drifted,
24210            None,
24211            &session,
24212            &binding,
24213            &expected.provider,
24214            &current_root,
24215            false,
24216        ));
24217    }
24218
24219    #[test]
24220    fn session_record_context_export_preflight_requires_live_usable_source() {
24221        let mut source = record("claude", "record-context-export-preflight");
24222        source.state = ManagedSessionState::Live;
24223        assert!(session_record_context_export_source_is_usable(
24224            &source,
24225            &SessionStatus::Running,
24226            false,
24227        ));
24228        assert!(session_record_context_export_source_is_usable(
24229            &source,
24230            &SessionStatus::Exited { exit_code: Some(0) },
24231            false,
24232        ));
24233
24234        source.state = ManagedSessionState::Dormant;
24235        assert!(!session_record_context_export_source_is_usable(
24236            &source,
24237            &SessionStatus::Running,
24238            false,
24239        ));
24240        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24241        // the only caller that may treat a Dormant, cleanly-exited record as
24242        // still usable — the on-demand path above never does.
24243        assert!(session_record_context_export_source_is_usable(
24244            &source,
24245            &SessionStatus::Exited { exit_code: Some(0) },
24246            true,
24247        ));
24248        assert!(!session_record_context_export_source_is_usable(
24249            &source,
24250            &SessionStatus::Exited { exit_code: Some(0) },
24251            false,
24252        ));
24253        source.state = ManagedSessionState::Live;
24254        assert!(!session_record_context_export_source_is_usable(
24255            &source,
24256            &SessionStatus::Starting,
24257            false,
24258        ));
24259    }
24260
24261    #[test]
24262    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24263        let catalog = active_registry().unwrap();
24264        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24265        let workspace = WorkspaceConfig::new(
24266            WorkspaceId::new("primary").unwrap(),
24267            std::env::current_dir().unwrap(),
24268        )
24269        .unwrap();
24270        let shared = NodeShared::new(
24271            handle,
24272            "fixture-token".to_owned(),
24273            NodeId::new("node-context-export-conflict").unwrap(),
24274            vec![workspace],
24275            vec![agent("claude")],
24276        );
24277        let history = HistorySessionRecord {
24278            session_id: "provider-exact".to_owned(),
24279            title: None,
24280            cwd: None,
24281            model: None,
24282            message_count: 1,
24283            completed_turn_count: None,
24284            total_tokens: 0,
24285            messages: vec![gate4agent_types::HistoryMessageRecord {
24286                role: gate4agent_types::HistoryMessageRole::User,
24287                text: "bounded context".to_owned(),
24288            }],
24289        };
24290        let pack = NodeContextPack::export(
24291            ContextPackLineageReceipt {
24292                source_node_id: shared.node_id.clone(),
24293                source_session: terminal_address(1),
24294                source_provider: agent("claude"),
24295            },
24296            &history,
24297        )
24298        .unwrap();
24299        let context_id = pack.receipt().id.clone();
24300        let session = terminal_address(1);
24301        let identity = ProviderSessionIdentity {
24302            key: ProviderSessionKey::SessionId,
24303            id: history.session_id.clone(),
24304            transcript_path: None,
24305        };
24306        let mut expected = record("claude", "record-context-export-final-conflict");
24307        expected.state = ManagedSessionState::Live;
24308        expected.provider_session = Some(identity.clone());
24309        expected.active_session = Some(session.clone());
24310        let mut changed = expected.clone();
24311        changed.updated_at_unix_ms += 1;
24312        shared.insert_record(changed).unwrap();
24313
24314        let error = shared
24315            .commit_context_pack_for_session_record(
24316                &expected,
24317                Some(&identity),
24318                &session,
24319                "candidate-exact",
24320                &history,
24321                pack,
24322                false,
24323            )
24324            .unwrap_err();
24325
24326        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24327        assert!(shared
24328            .context_catalog
24329            .read()
24330            .unwrap_or_else(|poisoned| poisoned.into_inner())
24331            .get(&context_id)
24332            .is_none());
24333    }
24334
24335    #[tokio::test]
24336    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24337        let root = temporary_workspace_root("session-task-binding");
24338        std::fs::create_dir_all(&root).unwrap();
24339        let state_path = root.join("node-state.json");
24340        let catalog = active_registry().unwrap();
24341        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24342        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24343        let node_id = NodeId::new("node-1").unwrap();
24344        let mut shared = NodeShared::new(
24345            handle,
24346            "fixture-token".to_owned(),
24347            node_id.clone(),
24348            vec![workspace],
24349            vec![agent("claude")],
24350        );
24351        shared.state_path = Some(state_path.clone());
24352        let record = record("claude", "sr-task");
24353        let record_id = record.record_id.clone();
24354        shared.insert_record(record).unwrap();
24355        shared.persist_state().unwrap();
24356        shared
24357            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24358            .unwrap();
24359
24360        let minted = process_request_inner(
24361            &shared,
24362            77,
24363            ClientRole::Operator,
24364            NodeRequest::SetSessionTask {
24365                record_id: record_id.clone(),
24366                expected_revision: 0,
24367                target: SessionTaskTargetV1::New,
24368            },
24369        )
24370        .await
24371        .unwrap();
24372        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24373            panic!("session task mutation returned another response");
24374        };
24375        let minted_binding = minted.task_binding.clone().unwrap();
24376        let minted_task_id = minted_binding.task_id.clone().unwrap();
24377        assert_eq!(minted_binding.revision, 1);
24378        assert!(minted_task_id.as_str().starts_with("task-"));
24379        assert_eq!(minted_task_id.as_str().len(), 29);
24380
24381        let event_count = shared.current_sequence();
24382        let repeated = process_request_inner(
24383            &shared,
24384            77,
24385            ClientRole::Operator,
24386            NodeRequest::SetSessionTask {
24387                record_id: record_id.clone(),
24388                expected_revision: 1,
24389                target: SessionTaskTargetV1::Existing {
24390                    task_id: minted_task_id.clone(),
24391                },
24392            },
24393        )
24394        .await
24395        .unwrap();
24396        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24397        assert_eq!(shared.current_sequence(), event_count);
24398
24399        let stale = process_request_inner(
24400            &shared,
24401            77,
24402            ClientRole::Operator,
24403            NodeRequest::SetSessionTask {
24404                record_id: record_id.clone(),
24405                expected_revision: 0,
24406                target: SessionTaskTargetV1::Existing {
24407                    task_id: minted_task_id,
24408                },
24409            },
24410        )
24411        .await
24412        .unwrap_err();
24413        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24414
24415        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24416        let assigned = process_request_inner(
24417            &shared,
24418            77,
24419            ClientRole::Operator,
24420            NodeRequest::SetSessionTask {
24421                record_id: record_id.clone(),
24422                expected_revision: 1,
24423                target: SessionTaskTargetV1::Existing {
24424                    task_id: assigned_id.clone(),
24425                },
24426            },
24427        )
24428        .await
24429        .unwrap();
24430        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24431            panic!("session task assignment returned another response");
24432        };
24433        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24434        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24435
24436        let cleared = process_request_inner(
24437            &shared,
24438            77,
24439            ClientRole::Operator,
24440            NodeRequest::SetSessionTask {
24441                record_id: record_id.clone(),
24442                expected_revision: 2,
24443                target: SessionTaskTargetV1::Clear,
24444            },
24445        )
24446        .await
24447        .unwrap();
24448        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24449            panic!("session task clear returned another response");
24450        };
24451        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24452        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24453        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24454        assert_eq!(
24455            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24456            cleared.updated_at_unix_ms,
24457        );
24458
24459        let persisted = std::fs::read(&state_path).unwrap();
24460        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24461        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24462        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24463        std::fs::remove_dir_all(root).unwrap();
24464    }
24465
24466    #[test]
24467    fn session_task_capability_strips_snapshots_events_and_replies() {
24468        let mut bound = record("claude", "sr-task-projection");
24469        bound.updated_at_unix_ms = 2;
24470        bound.task_binding = Some(SessionTaskBindingV1 {
24471            revision: 1,
24472            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24473            changed_at_unix_ms: 2,
24474        });
24475        let request = NodeRequest::SetSessionTask {
24476            record_id: bound.record_id.clone(),
24477            expected_revision: 1,
24478            target: SessionTaskTargetV1::Clear,
24479        };
24480        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24481            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24482        }));
24483        assert!(!request_uses_unnegotiated_capability(
24484            &request,
24485            &baseline_capabilities().unwrap(),
24486        ));
24487        assert!(request_uses_unnegotiated_capability(&request, &[]));
24488
24489        let mut snapshot = terminal_test_shared().snapshot();
24490        snapshot.session_records.push(bound.clone());
24491        let mut reply = ResponseEnvelope {
24492            request_id: 1,
24493            result: Ok(NodeResponse::Resync {
24494                event_sequence: 1,
24495                oldest_available_sequence: 1,
24496                snapshot,
24497                events: vec![NodeEventEnvelope {
24498                    sequence: 1,
24499                    event: NodeEvent::SessionRecordUpserted {
24500                        record: bound.clone(),
24501                    },
24502                }],
24503            }),
24504        };
24505        project_response_without_session_task_binding(&mut reply);
24506        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24507            panic!("task projection changed the response kind");
24508        };
24509        assert!(snapshot.session_records[0].task_binding.is_none());
24510        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24511            panic!("task projection changed the event kind");
24512        };
24513        assert!(record.task_binding.is_none());
24514
24515        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24516            sequence: 2,
24517            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24518        });
24519        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24520            panic!("task event projection changed the event kind");
24521        };
24522        assert!(record.task_binding.is_none());
24523
24524        let mut direct = ResponseEnvelope {
24525            request_id: 2,
24526            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24527        };
24528        project_response_without_session_task_binding(&mut direct);
24529        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24530            panic!("task reply projection changed the response kind");
24531        };
24532        assert!(record.task_binding.is_none());
24533    }
24534
24535    #[test]
24536    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24537        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24538        let workspace = WorkspaceConfig::new(
24539            WorkspaceId::new("primary").unwrap(),
24540            std::env::current_dir().unwrap(),
24541        )
24542        .unwrap();
24543        let config = NodeServerConfig::new(
24544            DEFAULT_NODE_ENDPOINT,
24545            "fixture-token",
24546            NodeId::new("node-1").unwrap(),
24547            [workspace],
24548        )
24549        .unwrap();
24550        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24551    }
24552
24553    #[cfg(all(feature = "fixture", windows))]
24554    #[test]
24555    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24556        let root = temporary_workspace_root("provider-bundle-argv-hold");
24557        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24558        std::fs::create_dir_all(&root).unwrap();
24559        std::fs::create_dir_all(&outside).unwrap();
24560        let proof_path = root.join("provider.proof");
24561        let release_signal = root.join("release.signal");
24562        let config = NodeServerConfig::new(
24563            format!(
24564                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24565                std::process::id(),
24566            ),
24567            "fixture-token",
24568            NodeId::new("provider-bundle-hold-node").unwrap(),
24569            [WorkspaceConfig::new(
24570                WorkspaceId::new("primary").unwrap(),
24571                std::env::current_dir().unwrap(),
24572            )
24573            .unwrap()],
24574        )
24575        .unwrap()
24576        .with_spawn_profiles(SpawnProfileRegistry::new([
24577            crate::protocol::SpawnProfileDefaults {
24578                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24579                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24580                    .unwrap(),
24581                provider: agent("kimi"),
24582                mode: SessionMode::Pty,
24583                terminal_size: gate4agent_types::TerminalSize {
24584                    rows: 24,
24585                    columns: 80,
24586                },
24587                prompt: None,
24588                bundle_id: None,
24589                context_id: None,
24590                environment_profile_id: None,
24591            },
24592        ])
24593        .unwrap());
24594        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24595            config,
24596            agent("kimi"),
24597            proof_path.clone(),
24598            release_signal.clone(),
24599        )
24600        .unwrap();
24601        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24602
24603        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24604            agent("kimi"),
24605            proof_path.clone(),
24606            Some(release_signal.clone()),
24607        )
24608        .unwrap();
24609        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24610        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24611        let release_wait = wrapper
24612            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24613            .unwrap();
24614        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24615        assert!(proof_write < release_wait);
24616        assert!(release_wait < fixture_ready);
24617        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24618        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24619        assert_eq!(
24620            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24621            &[
24622                proof_path.to_string_lossy().into_owned(),
24623                release_signal.to_string_lossy().into_owned(),
24624            ],
24625        );
24626
24627        let error = NodeServer::provider_bundle_argv_fixture_spec(
24628            agent("kimi"),
24629            proof_path,
24630            Some(outside.join("release.signal")),
24631        )
24632        .unwrap_err();
24633        assert!(matches!(
24634            error,
24635            NodeServerError::Registry(message)
24636                if message == "provider bundle argv release path must share the proof parent"
24637        ));
24638        drop(server);
24639        std::fs::remove_dir_all(root).unwrap();
24640        std::fs::remove_dir_all(outside).unwrap();
24641    }
24642
24643    #[cfg(feature = "fixture")]
24644    #[test]
24645    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24646        let root = temporary_workspace_root("context-fixture-registry");
24647        std::fs::create_dir_all(&root).unwrap();
24648        let proof_path = root.join("context.proof");
24649        let config = NodeServerConfig::new(
24650            format!(
24651                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24652                std::process::id(),
24653            ),
24654            "fixture-token",
24655            NodeId::new("context-fixture-node").unwrap(),
24656            [WorkspaceConfig::new(
24657                WorkspaceId::new("primary").unwrap(),
24658                std::env::current_dir().unwrap(),
24659            )
24660            .unwrap()],
24661        )
24662        .unwrap();
24663        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24664        let mut enabled = server
24665            .shared
24666            .enabled_providers
24667            .iter()
24668            .map(AgentId::as_str)
24669            .collect::<Vec<_>>();
24670        enabled.sort_unstable();
24671        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24672        for provider in &server.shared.enabled_providers {
24673            assert!(server
24674                .shared
24675                .provider_adapter_contracts
24676                .iter()
24677                .any(|contract| {
24678                    &contract.provider == provider
24679                        && contract.family == AdapterFamily::History
24680                }));
24681        }
24682        drop(server);
24683        std::fs::remove_dir_all(root).unwrap();
24684    }
24685
24686    #[cfg(feature = "fixture")]
24687    #[test]
24688    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24689        let root = temporary_workspace_root("monitoring-context-fixture");
24690        std::fs::create_dir_all(&root).unwrap();
24691        let proof_path = root.join("context.proof");
24692        let standard = NodeServer::context_pack_fixture_catalog(
24693            proof_path.clone(),
24694            None,
24695            false,
24696        ).unwrap();
24697        let monitoring = NodeServer::context_pack_fixture_catalog(
24698            proof_path.clone(),
24699            None,
24700            true,
24701        ).unwrap();
24702        let claude = AgentId::new("claude").unwrap();
24703        let standard_claude = standard.get(&claude).unwrap();
24704        let monitoring_claude = monitoring.get(&claude).unwrap();
24705        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24706        // standard nor the monitoring fixture declares a hook adapter
24707        // anymore.
24708        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24709        assert_eq!(
24710            monitoring_claude.capabilities.adapters.history,
24711            standard_claude.capabilities.adapters.history,
24712        );
24713        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24714        let codex = AgentId::new("codex").unwrap();
24715        let standard_codex = standard.get(&codex).unwrap();
24716        let monitoring_codex = monitoring.get(&codex).unwrap();
24717        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24718        assert_eq!(
24719            monitoring_codex.capabilities.adapters.history,
24720            standard_codex.capabilities.adapters.history,
24721        );
24722        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24723        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24724            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24725            .unwrap();
24726        for required in [
24727            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24728            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24729            "@('role', 'text')",
24730            "g4a-private-provider-session-canary",
24731            "private-provider-login-identity",
24732            "$rawProof",
24733            "$env:GATE4AGENT_HOOK_URL",
24734            "g4a-private-provider-session-canary",
24735        ] {
24736            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24737        }
24738        for provider in ["grok", "kimi"] {
24739            let provider = AgentId::new(provider).unwrap();
24740            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24741        }
24742
24743        let config = NodeServerConfig::new(
24744            format!(
24745                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24746                std::process::id(),
24747            ),
24748            "fixture-token",
24749            NodeId::new("monitoring-context-fixture-node").unwrap(),
24750            [WorkspaceConfig::new(
24751                WorkspaceId::new("primary").unwrap(),
24752                std::env::current_dir().unwrap(),
24753            )
24754            .unwrap()],
24755        )
24756        .unwrap();
24757        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24758        assert!(server.shared.fixture_semantic_hook_policy);
24759        drop(server);
24760        std::fs::remove_dir_all(root).unwrap();
24761    }
24762
24763    #[cfg(feature = "fixture")]
24764    #[test]
24765    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24766        let root = temporary_workspace_root("kimi-context-only-fixture");
24767        std::fs::create_dir_all(&root).unwrap();
24768        let proof_path = root.join("context.proof");
24769        let standard = NodeServer::context_pack_fixture_catalog(
24770            proof_path.clone(),
24771            None,
24772            false,
24773        ).unwrap();
24774        let kimi = AgentId::new("kimi").unwrap();
24775        let context_only = NodeServer::context_pack_fixture_catalog(
24776            proof_path,
24777            Some(&kimi),
24778            false,
24779        )
24780        .unwrap();
24781
24782        let codex = AgentId::new("codex").unwrap();
24783        assert_eq!(
24784            standard.get(&codex).unwrap().launch.fixed_args,
24785            context_only.get(&codex).unwrap().launch.fixed_args,
24786        );
24787        let script = context_only
24788            .get(&kimi)
24789            .unwrap()
24790            .launch
24791            .fixed_args
24792            .iter()
24793            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24794            .expect("Kimi context-only validation script is missing");
24795        for required in [
24796            "$bundleArgs.Count -ne 0",
24797            "GATE4AGENT_CONTEXT_ROOT",
24798            "context-pack.json",
24799            "$entries.Count -ne 1",
24800            "g4a-context-pack-v1",
24801            "$document.PSObject.Properties['cwd']",
24802            "'claude', 'codex', 'grok', 'kimi'",
24803            "$hasUser",
24804            "$hasAssistant",
24805            "SHA256",
24806            "context-only",
24807        ] {
24808            assert!(script.contains(required), "missing validation: {required}");
24809        }
24810        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24811            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24812        }
24813        assert!(!standard
24814            .get(&kimi)
24815            .unwrap()
24816            .launch
24817            .fixed_args
24818            .iter()
24819            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24820
24821        std::fs::remove_dir_all(root).unwrap();
24822    }
24823
24824    #[cfg(all(feature = "fixture", windows))]
24825    #[tokio::test]
24826    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24827        let root = temporary_workspace_root("kimi-context-only-spawn");
24828        let workspace_root = root.join("workspace");
24829        std::fs::create_dir_all(&workspace_root).unwrap();
24830        let materialization_root = root.join("private-materializations");
24831        let proof_path = root.join("context.proof");
24832        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24833        let workspace_id = WorkspaceId::new("primary").unwrap();
24834        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24835        let endpoint = format!(
24836            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24837            std::process::id(),
24838            unix_time_ms(),
24839        );
24840        let spawn_profiles = SpawnProfileRegistry::new([
24841            crate::protocol::SpawnProfileDefaults {
24842                profile_id: profile_id.clone(),
24843                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24844                provider: agent("kimi"),
24845                mode: SessionMode::Pty,
24846                terminal_size: gate4agent_types::TerminalSize {
24847                    rows: 30,
24848                    columns: 120,
24849                },
24850                prompt: None,
24851                bundle_id: None,
24852                context_id: None,
24853                environment_profile_id: None,
24854            },
24855        ])
24856        .unwrap();
24857        let mut config = NodeServerConfig::new(
24858            &endpoint,
24859            "fixture-token",
24860            node_id.clone(),
24861            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24862        )
24863        .unwrap()
24864        .with_spawn_profiles(spawn_profiles)
24865        .with_session_environment_materialization(
24866            materialization_root,
24867            Arc::new(FixtureSecretResolver {
24868                deny: Arc::new(AtomicBool::new(false)),
24869            }),
24870        )
24871        .unwrap();
24872        config.fixture_raw_pty_runtime = true;
24873        let server = NodeServer::new_context_only_proof_fixture(
24874            config,
24875            agent("kimi"),
24876            proof_path.clone(),
24877        )
24878        .unwrap();
24879        let shared = Arc::clone(&server.shared);
24880        let shutdown = server.shutdown_handle();
24881
24882        let history = HistorySessionRecord {
24883            session_id: "context-only-source-history".to_owned(),
24884            title: None,
24885            cwd: None,
24886            model: None,
24887            message_count: 2,
24888            completed_turn_count: None,
24889            total_tokens: 0,
24890            messages: vec![
24891                gate4agent_types::HistoryMessageRecord {
24892                    role: gate4agent_types::HistoryMessageRole::User,
24893                    text: "continue the bounded parent run through native C2".to_owned(),
24894                },
24895                gate4agent_types::HistoryMessageRecord {
24896                    role: gate4agent_types::HistoryMessageRole::Assistant,
24897                    text: "the exact parent context is ready for the child run".to_owned(),
24898                },
24899            ],
24900        };
24901        let pack = NodeContextPack::export(
24902            ContextPackLineageReceipt {
24903                source_node_id: node_id.clone(),
24904                source_session: SessionAddress {
24905                    workspace_id: workspace_id.clone(),
24906                    session: SessionKey {
24907                        instance_id: AgentInstanceId(71),
24908                        generation: SessionGeneration(1),
24909                    },
24910                },
24911                source_provider: agent("codex"),
24912            },
24913            &history,
24914        )
24915        .unwrap();
24916        let expected_context_hash = digest(&SHA256, pack.bytes())
24917            .as_ref()
24918            .iter()
24919            .map(|byte| format!("{byte:02x}"))
24920            .collect::<String>();
24921        let context = shared
24922            .context_catalog
24923            .write()
24924            .unwrap_or_else(|poisoned| poisoned.into_inner())
24925            .insert(pack)
24926            .unwrap();
24927        let spec = SpawnSpec {
24928            target: crate::protocol::SpawnTarget {
24929                node_id,
24930                workspace_id: workspace_id.clone(),
24931                worktree_id: None,
24932            },
24933            profile_id,
24934            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24935                "target-kimi-r1",
24936            ).unwrap(),
24937            overrides: crate::protocol::SpawnOverrides {
24938                context_id: crate::protocol::SpawnOverride::Set {
24939                    value: context.id.clone(),
24940                },
24941                ..crate::protocol::SpawnOverrides::default()
24942            },
24943            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24944            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24945            required_capabilities: SpawnRequiredCapabilities::default(),
24946        };
24947        let resolved = match shared.resolve_spawn_spec(&spec) {
24948            Ok(resolved) => resolved,
24949            Err(error) => panic!(
24950                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24951                error.code,
24952                error.message,
24953            ),
24954        };
24955        let resolved_context = match shared.resolve_context(&resolved) {
24956            Ok(Some(resolved_context)) => resolved_context,
24957            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24958            Err(error) => panic!(
24959                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24960                error.code,
24961                error.message,
24962            ),
24963        };
24964        assert_eq!(resolved_context, context);
24965        let runtime_policy = match shared
24966            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24967            .await
24968        {
24969            Ok(runtime_policy) => runtime_policy,
24970            Err(error) => panic!(
24971                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24972                error.code,
24973                error.message,
24974            ),
24975        };
24976        assert!(runtime_policy.raw_pty_lifecycle);
24977
24978        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24979        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24980            agent("kimi"),
24981            TransportKind::Pty,
24982            vec![EnvMutation {
24983                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24984                value: Some(OsString::from("must-remain-rejected")),
24985            }],
24986        )
24987        .unwrap();
24988        assert_eq!(
24989            launch_control
24990                .install_native_launch_environment_overlay(
24991                    AgentInstanceId(72),
24992                    generic_overlay,
24993                )
24994                .unwrap_err(),
24995            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24996        );
24997
24998        let diagnostic_address = SessionAddress {
24999            workspace_id: workspace_id.clone(),
25000            session: SessionKey {
25001                instance_id: AgentInstanceId(73),
25002                generation: SessionGeneration(1),
25003            },
25004        };
25005        let (diagnostic_overlay, diagnostic_materialization) = match shared
25006            .prepare_session_materialization(
25007                &diagnostic_address,
25008                &agent("kimi"),
25009                SessionMode::Pty,
25010                None,
25011                None,
25012                Some(&resolved_context),
25013                None,
25014            )
25015        {
25016            Ok(Some(prepared)) => prepared,
25017            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25018            Err(error) => panic!(
25019                "Kimi context-only materialization rejected: exact_code={:?} message={}",
25020                error.code,
25021                error.message,
25022            ),
25023        };
25024        let diagnostic_overlay = diagnostic_overlay
25025            .expect("Kimi context-only materialization omitted its environment overlay");
25026        assert!(matches!(
25027            &diagnostic_overlay,
25028            PreparedNativeLaunchOverlay::Environment(_),
25029        ));
25030        match shared.install_prepared_launch_overlay(
25031            diagnostic_address.session.instance_id,
25032            diagnostic_overlay,
25033        ) {
25034            Ok(None) => {}
25035            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25036            Err(error) => panic!(
25037                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25038                error.code,
25039                error.message,
25040            ),
25041        }
25042        assert!(launch_control.clear_native_instance_launch_overlay(
25043            diagnostic_address.session.instance_id,
25044        ));
25045        drop(diagnostic_materialization);
25046        assert!(shared
25047            .materializations
25048            .lock()
25049            .unwrap_or_else(|poisoned| poisoned.into_inner())
25050            .is_empty());
25051
25052        let node_task = tokio::spawn(server.run());
25053        let ready_client = timeout(Duration::from_secs(5), async {
25054            loop {
25055                match gate4agent_node_wire::LocalNodeClient::connect(
25056                    &endpoint,
25057                    &shared.node_id,
25058                    ClientRole::Observer,
25059                    "fixture-token",
25060                )
25061                .await
25062                {
25063                    Ok(client) => break client,
25064                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25065                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25066                }
25067            }
25068        })
25069        .await
25070        .expect("Kimi context-only Node did not become ready");
25071        assert_eq!(
25072            ready_client
25073                .hello()
25074                .snapshot
25075                .provider_runtime_statuses
25076                .iter()
25077                .find(|status| status.provider().as_str() == "kimi")
25078                .expect("Kimi runtime status is missing")
25079                .mode(),
25080            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25081        );
25082        drop(ready_client);
25083
25084        let receipt = match shared.spawn_from_spec(spec).await {
25085            Ok(receipt) => receipt,
25086            Err(error) => panic!(
25087                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25088                error.code,
25089                error.message,
25090            ),
25091        };
25092        assert_eq!(receipt.provider, agent("kimi"));
25093        assert_eq!(receipt.context.as_ref(), Some(&context));
25094        assert!(receipt.context_binding_is_valid());
25095        timeout(Duration::from_secs(5), async {
25096            while !proof_path.is_file() {
25097                sleep(Duration::from_millis(10)).await;
25098            }
25099        })
25100        .await
25101        .expect("Kimi context-only fixture did not write its proof");
25102
25103        let proof = std::fs::read_to_string(&proof_path).unwrap();
25104        let lines = proof.lines().collect::<Vec<_>>();
25105        assert_eq!(lines.len(), 7);
25106        assert_eq!(lines[0], "context-only");
25107        assert!(Path::new(lines[1]).is_absolute());
25108        assert_ne!(
25109            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25110            std::fs::canonicalize(&workspace_root).unwrap(),
25111        );
25112        assert_eq!(
25113            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25114            std::fs::canonicalize(&workspace_root).unwrap(),
25115        );
25116        assert_eq!(lines[3], expected_context_hash);
25117        assert_eq!(lines[4], "g4a-context-pack-v1");
25118        assert_eq!(lines[5], "codex");
25119        assert_eq!(lines[6], "2");
25120        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25121            session.instance_id == receipt.session.session.instance_id
25122                && session.agent_id.as_str() == "kimi"
25123        }));
25124
25125        shutdown.request_shutdown().await.unwrap();
25126        timeout(Duration::from_secs(5), node_task)
25127            .await
25128            .unwrap()
25129            .unwrap()
25130            .unwrap();
25131        drop(shutdown);
25132        drop(shared);
25133        std::fs::remove_dir_all(root).unwrap();
25134    }
25135
25136    #[cfg(feature = "fixture")]
25137    #[test]
25138    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25139        let root = temporary_workspace_root("clean-exit-constructor");
25140        std::fs::create_dir_all(&root).unwrap();
25141        let config = construction_test_config("clean-exit-constructor")
25142            .with_state_path(root.join("node-state.json"))
25143            .unwrap();
25144        let server = NodeServer::new_clean_exit_fixture(
25145            config,
25146            root.clone(),
25147            root.join("started.marker"),
25148            root.join("release.signal"),
25149        )
25150        .unwrap();
25151        assert_eq!(
25152            server
25153                .shared
25154                .enabled_providers
25155                .iter()
25156                .map(AgentId::as_str)
25157                .collect::<Vec<_>>(),
25158            ["claude"],
25159        );
25160        drop(server);
25161        std::fs::remove_dir_all(root).unwrap();
25162    }
25163
25164    #[test]
25165    fn production_registry_provider_contract_manifest_is_exact() {
25166        let registry = active_registry().unwrap();
25167        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25168        assert_eq!(
25169            providers
25170                .iter()
25171                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25172                .collect::<Vec<_>>(),
25173            vec![
25174                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25175                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25176                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25177                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25178            ]
25179        );
25180        assert_eq!(
25181            adapters
25182                .iter()
25183                .map(|contract| (
25184                    contract.provider.as_str(),
25185                    contract.family,
25186                    contract.adapter_id.as_str(),
25187                    contract.revision.as_str(),
25188                ))
25189                .collect::<Vec<_>>(),
25190            vec![
25191                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25192                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25193                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25194                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25195                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25196                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25197                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25198                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25199                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25200                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25201                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25202                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25203                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25204                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25205                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25206                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25207                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25208                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25209                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25210                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25211                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25212                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25213                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25214            ]
25215        );
25216    }
25217
25218    fn construction_test_config(name: &str) -> NodeServerConfig {
25219        let workspace = WorkspaceConfig::new(
25220            WorkspaceId::new("primary").unwrap(),
25221            std::env::current_dir().unwrap(),
25222        )
25223        .unwrap();
25224        NodeServerConfig::new(
25225            format!(r"\\.\pipe\gate4agent-{name}"),
25226            "fixture-token",
25227            NodeId::new(name).unwrap(),
25228            [workspace],
25229        )
25230        .unwrap()
25231    }
25232
25233    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25234        let adapter = AdapterBinding::new(
25235            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25236            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25237            gate4agent_types::AdapterVerification::SyntheticFixture,
25238        )
25239        .unwrap();
25240        let agent_ids = ["claude", "codex", "kimi"]
25241            .into_iter()
25242            .map(|agent_id| AgentId::new(agent_id).unwrap())
25243            .collect::<Vec<_>>();
25244        let adapters = gate4agent_catalog::AdapterRegistry::new(
25245            [
25246                AdapterFamily::PtySemantic,
25247                AdapterFamily::Pipe,
25248                AdapterFamily::OneShot,
25249                AdapterFamily::Acp,
25250                AdapterFamily::Hook,
25251                AdapterFamily::ManagedHook,
25252                AdapterFamily::History,
25253                AdapterFamily::Resume,
25254                AdapterFamily::SessionOptions,
25255                AdapterFamily::CapabilityProbe,
25256            ]
25257            .into_iter()
25258            .map(|family| gate4agent_catalog::AdapterDescriptor {
25259                family,
25260                binding: adapter.clone(),
25261                agents: agent_ids.clone(),
25262            }),
25263        )
25264        .unwrap();
25265        let specs = agent_ids
25266            .iter()
25267            .map(|agent_id| {
25268                let agent_id = agent_id.as_str();
25269                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25270                spec.revision =
25271                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25272                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25273                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25274                    adapter: adapter.clone(),
25275                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25276                    launch_override: None,
25277                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25278                });
25279                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25280                    adapter: adapter.clone(),
25281                    launch_override: None,
25282                });
25283                spec.capabilities.adapters.hook = Some(adapter.clone());
25284                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25285                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25286                spec.capabilities.adapters.history = Some(adapter.clone());
25287                spec.capabilities.adapters.resume = Some(adapter.clone());
25288                spec.capabilities.adapters.session_options = Some(adapter.clone());
25289                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25290                spec
25291            })
25292            .collect::<Vec<_>>();
25293        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25294    }
25295
25296    /// The real fleet's manifest is accepted by a real server construction.
25297    ///
25298    /// The count is asserted against `active_registry` itself rather than a
25299    /// literal, so this stays a test that construction succeeds for whatever
25300    /// the fleet is -- which is the property worth guarding -- instead of a
25301    /// second place the fleet size is written down and has to be kept in
25302    /// step with the first.
25303    #[test]
25304    fn node_server_construction_accepts_the_production_manifest() {
25305        let registry = active_registry().unwrap();
25306        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25307        assert_eq!(providers.len(), registry.iter().count());
25308        assert!(!providers.is_empty());
25309        let server = NodeServer::new_with_registry(
25310            construction_test_config("manifest-production-bound"),
25311            registry,
25312        )
25313        .unwrap();
25314        drop(server);
25315    }
25316
25317    #[test]
25318    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25319        let registry = maximum_size_provider_manifest_registry();
25320        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25321        assert_eq!(providers.len(), 3);
25322        assert_eq!(adapters.len(), 30);
25323        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25324        let error = match NodeServer::new_with_registry(
25325            construction_test_config("manifest-handshake-overflow"),
25326            registry,
25327        ) {
25328            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25329            Err(error) => error,
25330        };
25331        assert!(matches!(
25332            error,
25333            NodeServerError::ProviderContractManifest(message)
25334                if message.contains("authentication binding length")
25335                    && message.contains("8192-byte limit")
25336        ));
25337    }
25338
25339    #[test]
25340    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25341        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25342        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25343        let registry = AgentRegistry::new([spec]).unwrap();
25344        assert!(matches!(
25345            provider_contract_manifest(&registry),
25346            Err(NodeServerError::ProviderContractManifest(message))
25347                if message.contains("exceeds the 128-byte limit")
25348        ));
25349    }
25350
25351    #[test]
25352    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25353        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25354        spec.revision = "invalid revision".to_owned();
25355        let registry = AgentRegistry::new([spec]).unwrap();
25356        assert!(matches!(
25357            provider_contract_manifest(&registry),
25358            Err(NodeServerError::ProviderContractManifest(message))
25359                if message.contains("bounded lowercase ASCII")
25360        ));
25361    }
25362
25363    #[test]
25364    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25365        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25366        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25367        let conflicting_binding = builtin_registry()
25368            .get_by_id("codex")
25369            .unwrap()
25370            .capabilities
25371            .adapters
25372            .one_shot
25373            .clone()
25374            .unwrap();
25375        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25376        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25377        pipe.adapter = transport_binding;
25378        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25379        let registry = AgentRegistry::new([spec]).unwrap();
25380        assert!(matches!(
25381            provider_contract_manifest(&registry),
25382            Err(NodeServerError::ProviderContractManifest(message))
25383                if message.contains("conflicting OneShot adapter bindings")
25384        ));
25385    }
25386
25387    #[test]
25388    fn public_node_failures_expose_only_fixed_categories() {
25389        let provider = failure(
25390            NodeFailureCode::BackendOperationFailed,
25391            r"provider bearer-secret failed at C:\private\session.jsonl",
25392        );
25393        assert_eq!(provider.message, "backend-operation-failed");
25394        assert!(!provider.message.contains("bearer-secret"));
25395        assert!(!provider.message.contains(r"C:\private"));
25396        let persistence = persistence_failure(io::Error::new(
25397            io::ErrorKind::Other,
25398            r"state write failed at C:\private\state-v1.json",
25399        ));
25400        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25401        assert!(!persistence.message.contains(r"C:\private"));
25402
25403        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25404            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25405            assert_eq!(
25406                generic.to_string(),
25407                "node durable state failed: durable-state-load-failed",
25408            );
25409        }
25410    }
25411
25412    #[test]
25413    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25414        let sequence = b"\x1b[1;5D".to_vec();
25415        assert_eq!(
25416            terminal_bytes_action(sequence.clone()).unwrap(),
25417            InputAction::TerminalBytes(sequence),
25418        );
25419        assert_eq!(
25420            terminal_bytes_action(Vec::new()).unwrap_err().code,
25421            NodeFailureCode::InvalidRequest,
25422        );
25423        assert_eq!(
25424            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25425                .unwrap_err()
25426                .code,
25427            NodeFailureCode::InvalidRequest,
25428        );
25429    }
25430
25431    #[test]
25432    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25433        let root = temporary_workspace_root("exclusive-state-owner");
25434        std::fs::create_dir_all(&root).unwrap();
25435        let workspace = WorkspaceConfig::new(
25436            WorkspaceId::new("primary").unwrap(),
25437            &root,
25438        )
25439        .unwrap();
25440        let state_path = root.join("state-v1.json");
25441        let endpoint = format!(
25442            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25443            std::process::id(),
25444            std::time::SystemTime::now()
25445                .duration_since(std::time::UNIX_EPOCH)
25446                .unwrap()
25447                .as_nanos(),
25448        );
25449        let config = NodeServerConfig::new(
25450            endpoint,
25451            "fixture-token",
25452            NodeId::new("node-1").unwrap(),
25453            [workspace],
25454        )
25455        .unwrap()
25456        .with_state_path(&state_path)
25457        .unwrap();
25458
25459        let first = NodeServer::new(config.clone()).unwrap();
25460        let error = match NodeServer::new(config.clone()) {
25461            Ok(_) => panic!("a second node server acquired the same durable state path"),
25462            Err(error) => error,
25463        };
25464        assert!(matches!(
25465            &error,
25466            NodeServerError::DurableState(error)
25467                if error.kind() == io::ErrorKind::WouldBlock
25468        ));
25469        assert_eq!(
25470            error.to_string(),
25471            "node durable state failed: durable-state-lock-failed",
25472        );
25473        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25474        drop(first);
25475        let second = NodeServer::new(config).unwrap();
25476        drop(second);
25477        std::fs::remove_dir_all(root).unwrap();
25478    }
25479
25480    #[test]
25481    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25482        let catalog = active_registry().unwrap();
25483        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25484        let workspace_id = WorkspaceId::new("primary").unwrap();
25485        let workspace = WorkspaceConfig::new(
25486            workspace_id.clone(),
25487            std::env::current_dir().unwrap(),
25488        )
25489        .unwrap();
25490        let shared = NodeShared::new(
25491            handle,
25492            "fixture-token".to_owned(),
25493            NodeId::new("node-1").unwrap(),
25494            vec![workspace.clone()],
25495            vec![agent("claude"), agent("codex"), agent("kimi")],
25496        );
25497        let instance_id = AgentInstanceId(17);
25498        let generation = SessionGeneration(3);
25499        assert!(shared.address_for(instance_id, generation).is_none());
25500        shared.bind_session(instance_id, workspace_id.clone(), generation);
25501        let address = SessionAddress {
25502            workspace_id,
25503            session: SessionKey { instance_id, generation },
25504        };
25505        assert_eq!(
25506            shared.workspace_root(&address.workspace_id).unwrap(),
25507            workspace.canonical_root,
25508        );
25509        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25510        shared.remove_binding(&address);
25511        assert!(shared.address_for(instance_id, generation).is_none());
25512    }
25513
25514    #[test]
25515    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25516        let address = SessionAddress {
25517            workspace_id: WorkspaceId::new("primary").unwrap(),
25518            session: SessionKey {
25519                instance_id: AgentInstanceId(7),
25520                generation: SessionGeneration(2),
25521            },
25522        };
25523        let record_id = SessionRecordId::new("record-a").unwrap();
25524        let size = gate4agent_types::TerminalSize {
25525            rows: 24,
25526            columns: 80,
25527        };
25528        let address_requests = [
25529            NodeRequest::Resume {
25530                session: address.clone(),
25531                terminal_size: size,
25532                initial_prompt: None,
25533            },
25534            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25535            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25536            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25537            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25538            NodeRequest::TerminalControl {
25539                session: address.clone(),
25540                control: TerminalControl::Enter,
25541            },
25542            NodeRequest::Resize { session: address.clone(), size },
25543            NodeRequest::Interrupt { session: address.clone() },
25544            NodeRequest::Stop { session: address.clone(), force: false },
25545            NodeRequest::Remove { session: address },
25546        ];
25547        for request in &address_requests {
25548            assert!(request_requires_open_provider_ids_with(
25549                request,
25550                |_| Some(agent("third-party-agent")),
25551                |_| Some(agent("claude")),
25552            ));
25553            assert!(!request_requires_open_provider_ids_with(
25554                request,
25555                |_| Some(agent("claude")),
25556                |_| Some(agent("claude")),
25557            ));
25558            assert!(request_requires_open_provider_ids_with(
25559                request,
25560                |_| None,
25561                |_| Some(agent("claude")),
25562            ));
25563        }
25564
25565        let record_requests = [
25566            NodeRequest::RenameSessionRecord {
25567                record_id: record_id.clone(),
25568                display_name: "renamed".to_owned(),
25569            },
25570            NodeRequest::ResumeSessionRecord {
25571                record_id: record_id.clone(),
25572                terminal_size: size,
25573                initial_prompt: None,
25574            },
25575            NodeRequest::ForgetSessionRecord { record_id },
25576        ];
25577        for request in &record_requests {
25578            assert!(request_requires_open_provider_ids_with(
25579                request,
25580                |_| Some(agent("claude")),
25581                |_| Some(agent("grok")),
25582            ));
25583            assert!(!request_requires_open_provider_ids_with(
25584                request,
25585                |_| Some(agent("claude")),
25586                |_| Some(agent("kimi")),
25587            ));
25588            assert!(request_requires_open_provider_ids_with(
25589                request,
25590                |_| Some(agent("claude")),
25591                |_| None,
25592            ));
25593        }
25594
25595        assert!(request_requires_open_provider_ids_with(
25596            &NodeRequest::Spawn {
25597                workspace_id: WorkspaceId::new("primary").unwrap(),
25598                provider: agent("third-party-agent"),
25599                mode: SessionMode::Pty,
25600                terminal_size: size,
25601                initial_prompt: None,
25602            },
25603            |_| None,
25604            |_| None,
25605        ));
25606        assert!(!request_requires_open_provider_ids_with(
25607            &NodeRequest::Snapshot,
25608            |_| None,
25609            |_| None,
25610        ));
25611        assert!(request_requires_open_provider_ids_with(
25612            &NodeRequest::ForgetContextPack {
25613                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25614            },
25615            |_| None,
25616            |_| None,
25617        ));
25618    }
25619
25620    #[test]
25621    fn legacy_projection_filters_open_record_replies_and_event_history() {
25622        let catalog = active_registry().unwrap();
25623        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624        let workspace = WorkspaceConfig::new(
25625            WorkspaceId::new("primary").unwrap(),
25626            std::env::current_dir().unwrap(),
25627        )
25628        .unwrap();
25629        let shared = NodeShared::new(
25630            handle,
25631            "fixture-token".to_owned(),
25632            NodeId::new("node-1").unwrap(),
25633            vec![workspace],
25634            vec![agent("claude"), agent("grok")],
25635        );
25636        let legacy = record("claude", "legacy-record");
25637        let open = record("grok", "open-record");
25638        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25639            record: legacy.clone(),
25640        });
25641        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25642            record: open.clone(),
25643        });
25644        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25645            record_id: legacy.record_id.clone(),
25646        });
25647        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25648            record_id: open.record_id.clone(),
25649        });
25650        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25651        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25652        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25653        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25654
25655        let mut record_reply = ResponseEnvelope {
25656            request_id: 1,
25657            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25658        };
25659        project_response_legacy_provider_ids(&shared, &mut record_reply);
25660        assert!(matches!(
25661            record_reply.result,
25662            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25663        ));
25664
25665        let mut resync = ResponseEnvelope {
25666            request_id: 2,
25667            result: Ok(NodeResponse::Resync {
25668                event_sequence: open_removed.sequence,
25669                oldest_available_sequence: 1,
25670                snapshot: shared.snapshot(),
25671                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25672            }),
25673        };
25674        project_response_legacy_provider_ids(&shared, &mut resync);
25675        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25676            panic!("legacy projection changed the resync response kind");
25677        };
25678        assert!(snapshot.session_records.iter().all(|item| {
25679            provider_id_is_legacy(&item.provider)
25680        }));
25681        assert_eq!(events.len(), 2);
25682        assert!(events.iter().all(|event| matches!(
25683            &event.event,
25684            NodeEvent::SessionRecordUpserted { record }
25685                if provider_id_is_legacy(&record.provider)
25686        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25687    }
25688
25689    #[test]
25690    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25691        let catalog = active_registry().unwrap();
25692        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25693        let workspace_id = WorkspaceId::new("primary").unwrap();
25694        let workspace = WorkspaceConfig::new(
25695            workspace_id.clone(),
25696            std::env::current_dir().unwrap(),
25697        )
25698        .unwrap();
25699        let shared = NodeShared::new(
25700            handle,
25701            "fixture-token".to_owned(),
25702            NodeId::new("node-1").unwrap(),
25703            vec![workspace],
25704            vec![agent("claude")],
25705        );
25706        for index in 0..CONTROL_SESSIONS_MAX {
25707            shared.bind_session(
25708                AgentInstanceId((index + 1) as u64),
25709                workspace_id.clone(),
25710                SessionGeneration(1),
25711            );
25712        }
25713        let error = shared.ensure_binding_capacity().unwrap_err();
25714        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25715    }
25716
25717    #[tokio::test]
25718    async fn provider_spawn_admission_rejects_before_session_mutation() {
25719        let catalog = active_registry().unwrap();
25720        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25721        let workspace = WorkspaceConfig::new(
25722            WorkspaceId::new("primary").unwrap(),
25723            std::env::current_dir().unwrap(),
25724        )
25725        .unwrap();
25726        let mut shared = NodeShared::new(
25727            handle,
25728            "fixture-token".to_owned(),
25729            NodeId::new("node-provider-admission").unwrap(),
25730            vec![workspace],
25731            vec![agent("claude")],
25732        );
25733        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25734            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25735        ])
25736        .unwrap();
25737        let terminal_size = gate4agent_types::TerminalSize {
25738            rows: 24,
25739            columns: 80,
25740        };
25741
25742        let unavailable = shared
25743            .spawn_session(
25744                WorkspaceId::new("primary").unwrap(),
25745                agent("claude"),
25746                SessionMode::Pty,
25747                terminal_size,
25748                None,
25749            )
25750            .await
25751            .unwrap_err();
25752        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25753        assert_eq!(unavailable.message, "backend-operation-failed");
25754
25755        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25756            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25757                agent("claude"),
25758                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25759            ),
25760        ])
25761        .unwrap();
25762        let semantic = shared
25763            .spawn_session(
25764                WorkspaceId::new("primary").unwrap(),
25765                agent("claude"),
25766                SessionMode::Inline,
25767                terminal_size,
25768                None,
25769            )
25770            .await
25771            .unwrap_err();
25772        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25773        assert_eq!(semantic.message, "unsupported-capability");
25774
25775        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25776        assert!(shared
25777            .session_bindings
25778            .lock()
25779            .unwrap_or_else(|poisoned| poisoned.into_inner())
25780            .is_empty());
25781        assert!(shared
25782            .session_records
25783            .lock()
25784            .unwrap_or_else(|poisoned| poisoned.into_inner())
25785            .records
25786            .is_empty());
25787        assert!(shared.handle.snapshot().sessions.is_empty());
25788    }
25789
25790    #[tokio::test]
25791    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25792        let catalog = active_registry().unwrap();
25793        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25794        let workspace_id = WorkspaceId::new("primary").unwrap();
25795        let workspace = WorkspaceConfig::new(
25796            workspace_id.clone(),
25797            std::env::current_dir().unwrap(),
25798        )
25799        .unwrap();
25800        let shared = NodeShared::new(
25801            handle,
25802            "fixture-token".to_owned(),
25803            NodeId::new("node-raw-spawn-record").unwrap(),
25804            vec![workspace],
25805            vec![agent("claude")],
25806        );
25807        let address = SessionAddress {
25808            workspace_id,
25809            session: SessionKey {
25810                instance_id: AgentInstanceId(71),
25811                generation: SessionGeneration::default(),
25812            },
25813        };
25814
25815        let record_id = shared
25816            .bind_spawn_session(
25817                &address,
25818                agent("claude"),
25819                SessionMode::Pty,
25820                ProviderRuntimePolicy::raw_pty(),
25821                SpawnRecordPolicy::Always,
25822                None,
25823            )
25824            .unwrap()
25825            .unwrap();
25826        shared
25827            .handle
25828            .dispatch(shared.prepare_command(ControlCommand::Register {
25829                instance_id: address.session.instance_id,
25830                agent_id: agent("claude"),
25831                transport: TransportKind::Pty,
25832            }))
25833            .unwrap();
25834        runtime.tick().await;
25835
25836        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25837            session.instance_id == address.session.instance_id
25838                && session.generation == address.session.generation
25839        }));
25840        let record = shared.record(&record_id).unwrap();
25841        assert_eq!(record.display_name, "claude #1");
25842        assert_eq!(record.state, ManagedSessionState::Live);
25843        assert_eq!(record.active_session.as_ref(), Some(&address));
25844        assert!(record.provider_session.is_none());
25845        let binding_record_id = shared
25846            .session_bindings
25847            .lock()
25848            .unwrap_or_else(|poisoned| poisoned.into_inner())
25849            .get(&address.session.instance_id)
25850            .and_then(|binding| binding.record_id.clone());
25851        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25852
25853        let renamed = shared
25854            .rename_session_record(&record_id, "raw review".to_owned())
25855            .unwrap();
25856        assert_eq!(renamed.record_id, record_id);
25857        assert_eq!(renamed.display_name, "raw review");
25858        shared.reconcile_managed_record(
25859            &record_id,
25860            &address,
25861            &ControlEvent {
25862                sequence: 1,
25863                command_id: None,
25864                instance_id: address.session.instance_id,
25865                generation: address.session.generation,
25866                event: ControlEventKind::Running { process_id: None },
25867            },
25868            false,
25869            false,
25870        );
25871        let running = shared.record(&record_id).unwrap();
25872        assert_eq!(running.state, ManagedSessionState::Live);
25873        assert_eq!(running.active_session.as_ref(), Some(&address));
25874        assert_eq!(running.display_name, "raw review");
25875
25876        shared.reconcile_managed_record(
25877            &record_id,
25878            &address,
25879            &ControlEvent {
25880                sequence: 2,
25881                command_id: None,
25882                instance_id: address.session.instance_id,
25883                generation: address.session.generation,
25884                event: ControlEventKind::Exited {
25885                    exit_code: Some(0),
25886                    forced: false,
25887                },
25888            },
25889            false,
25890            false,
25891        );
25892        let detached = shared.record(&record_id).unwrap();
25893        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25894        assert!(detached.active_session.is_none());
25895        assert_eq!(detached.display_name, "raw review");
25896        let renamed_detached = shared
25897            .rename_session_record(&record_id, "raw review detached".to_owned())
25898            .unwrap();
25899        assert_eq!(renamed_detached.record_id, record_id);
25900        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25901        assert!(renamed_detached.active_session.is_none());
25902        assert_eq!(renamed_detached.display_name, "raw review detached");
25903        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25904    }
25905
25906    #[test]
25907    fn legacy_raw_spawn_binding_remains_untracked() {
25908        let catalog = active_registry().unwrap();
25909        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25910        let workspace_id = WorkspaceId::new("primary").unwrap();
25911        let workspace = WorkspaceConfig::new(
25912            workspace_id.clone(),
25913            std::env::current_dir().unwrap(),
25914        )
25915        .unwrap();
25916        let shared = NodeShared::new(
25917            handle,
25918            "fixture-token".to_owned(),
25919            NodeId::new("node-legacy-raw-spawn").unwrap(),
25920            vec![workspace],
25921            vec![agent("claude")],
25922        );
25923        let address = SessionAddress {
25924            workspace_id,
25925            session: SessionKey {
25926                instance_id: AgentInstanceId(72),
25927                generation: SessionGeneration::default(),
25928            },
25929        };
25930
25931        let record_id = shared
25932            .bind_spawn_session(
25933                &address,
25934                agent("claude"),
25935                SessionMode::Pty,
25936                ProviderRuntimePolicy::raw_pty(),
25937                SpawnRecordPolicy::ProviderIdentityOnly,
25938                None,
25939            )
25940            .unwrap();
25941
25942        assert!(record_id.is_none());
25943        let binding_record_id = shared
25944            .session_bindings
25945            .lock()
25946            .unwrap_or_else(|poisoned| poisoned.into_inner())
25947            .get(&address.session.instance_id)
25948            .and_then(|binding| binding.record_id.clone());
25949        assert!(binding_record_id.is_none());
25950        assert!(shared.snapshot().session_records.is_empty());
25951    }
25952
25953    #[tokio::test]
25954    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25955        let catalog = active_registry().unwrap();
25956        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25957        let workspace_id = WorkspaceId::new("primary").unwrap();
25958        let workspace = WorkspaceConfig::new(
25959            workspace_id.clone(),
25960            std::env::current_dir().unwrap(),
25961        )
25962        .unwrap();
25963        let mut shared = NodeShared::new(
25964            handle,
25965            "fixture-token".to_owned(),
25966            NodeId::new("node-raw-semantic-gate").unwrap(),
25967            vec![workspace],
25968            vec![agent("claude")],
25969        );
25970        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25971            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25972        ])
25973        .unwrap();
25974        shared
25975            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25976            .unwrap();
25977        let address = SessionAddress {
25978            workspace_id,
25979            session: SessionKey {
25980                instance_id: AgentInstanceId(72),
25981                generation: SessionGeneration::default(),
25982            },
25983        };
25984        shared.bind_session_with_policy(
25985            &address,
25986            ProviderRuntimePolicy::raw_pty(),
25987            None,
25988        );
25989        shared
25990            .handle
25991            .dispatch(shared.prepare_command(ControlCommand::Register {
25992                instance_id: address.session.instance_id,
25993                agent_id: agent("claude"),
25994                transport: TransportKind::Pty,
25995            }))
25996            .unwrap();
25997        runtime.tick().await;
25998        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25999        let revision_before = shared.handle.snapshot().revision;
26000        let history_sequence_before = shared.current_sequence();
26001        let terminal_size = gate4agent_types::TerminalSize {
26002            rows: 24,
26003            columns: 80,
26004        };
26005
26006        for request in [
26007            NodeRequest::Prompt {
26008                session: address.clone(),
26009                text: "prompt".to_owned(),
26010            },
26011            NodeRequest::Paste {
26012                session: address.clone(),
26013                text: "paste".to_owned(),
26014            },
26015            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
26016            // the same semantic capabilities as `Prompt`/`Paste`. A bare
26017            // (promptless) resume is a raw PTY relaunch and IS admitted under
26018            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
26019            // in `provider_runtime.rs`; it is deliberately not exercised here.
26020            NodeRequest::Resume {
26021                session: address.clone(),
26022                terminal_size,
26023                initial_prompt: Some("resume".to_owned()),
26024            },
26025        ] {
26026            let error = process_request_inner(
26027                &shared,
26028                77,
26029                ClientRole::Operator,
26030                request,
26031            )
26032            .await
26033            .unwrap_err();
26034            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26035        }
26036
26037        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26038        assert_eq!(shared.handle.snapshot().revision, revision_before);
26039        assert_eq!(shared.current_sequence(), history_sequence_before);
26040        let bindings = shared
26041            .session_bindings
26042            .lock()
26043            .unwrap_or_else(|poisoned| poisoned.into_inner());
26044        let binding = bindings.get(&address.session.instance_id).unwrap();
26045        assert_eq!(binding.generation, address.session.generation);
26046        assert!(binding.pending_resume.is_none());
26047        assert!(binding.record_id.is_none());
26048    }
26049
26050    #[tokio::test]
26051    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26052        let catalog = active_registry().unwrap();
26053        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26054        let primary = WorkspaceConfig::new(
26055            WorkspaceId::new("primary").unwrap(),
26056            std::env::current_dir().unwrap(),
26057        )
26058        .unwrap();
26059        let shared = NodeShared::new(
26060            handle,
26061            "fixture-token".to_owned(),
26062            NodeId::new("node-1").unwrap(),
26063            vec![primary],
26064            vec![agent("claude")],
26065        );
26066        shared
26067            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26068            .unwrap();
26069        let before = shared.snapshot();
26070        let error = process_request_inner(
26071            &shared,
26072            77,
26073            ClientRole::Operator,
26074            NodeRequest::RegisterWorkspace {
26075                workspace_id: WorkspaceId::new("foreign").unwrap(),
26076                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26077            },
26078        )
26079        .await
26080        .unwrap_err();
26081        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26082        assert_eq!(error.message, "invalid-request");
26083        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26084    }
26085
26086    #[tokio::test]
26087    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26088        let catalog = active_registry().unwrap();
26089        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26090        let primary_id = WorkspaceId::new("primary").unwrap();
26091        let primary = WorkspaceConfig::new(
26092            primary_id.clone(),
26093            std::env::current_dir().unwrap(),
26094        )
26095        .unwrap();
26096        let shared = NodeShared::new(
26097            handle,
26098            "fixture-token".to_owned(),
26099            NodeId::new("node-1").unwrap(),
26100            vec![primary.clone()],
26101            vec![agent("claude")],
26102        );
26103        let secondary_id = WorkspaceId::new("secondary").unwrap();
26104        let secondary = shared
26105            .register_workspace(
26106                secondary_id.clone(),
26107                std::env::temp_dir().to_string_lossy().into_owned(),
26108            )
26109            .await
26110            .unwrap();
26111        assert_eq!(secondary.workspace_id, secondary_id);
26112        assert!(secondary.sessions.is_empty());
26113        assert_eq!(shared.snapshot().workspaces.len(), 2);
26114
26115        let duplicate_id = shared
26116            .register_workspace(
26117                secondary_id.clone(),
26118                primary.canonical_root().to_owned(),
26119            )
26120            .await
26121            .unwrap_err();
26122        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26123        let duplicate_root = shared
26124            .register_workspace(
26125                WorkspaceId::new("duplicate-root").unwrap(),
26126                windows_path_text(&secondary.canonical_root).to_owned(),
26127            )
26128            .await
26129            .unwrap_err();
26130        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26131
26132        let address = SessionAddress {
26133            workspace_id: secondary_id.clone(),
26134            session: SessionKey {
26135                instance_id: AgentInstanceId(41),
26136                generation: SessionGeneration(1),
26137            },
26138        };
26139        shared.bind_session(
26140            address.session.instance_id,
26141            secondary_id.clone(),
26142            address.session.generation,
26143        );
26144        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26145        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26146        shared.remove_binding(&address);
26147        shared.unregister_workspace(&secondary_id).unwrap();
26148        assert_eq!(shared.snapshot().workspaces.len(), 1);
26149
26150        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26151        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26152        let history = shared
26153            .history
26154            .lock()
26155            .unwrap_or_else(|poisoned| poisoned.into_inner());
26156        assert!(matches!(
26157            &history.events[0],
26158            NodeEventEnvelope {
26159                sequence: 1,
26160                event: NodeEvent::WorkspaceAdded { workspace },
26161            } if workspace.workspace_id == secondary_id
26162        ));
26163        assert!(matches!(
26164            &history.events[1],
26165            NodeEventEnvelope {
26166                sequence: 2,
26167                event: NodeEvent::WorkspaceRemoved { workspace_id },
26168            } if workspace_id == &secondary_id
26169        ));
26170    }
26171
26172    #[tokio::test]
26173    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26174        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26175        std::fs::create_dir_all(&secondary_root).unwrap();
26176        let catalog = active_registry().unwrap();
26177        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26178        let primary_id = WorkspaceId::new("primary").unwrap();
26179        let secondary_id = WorkspaceId::new("secondary").unwrap();
26180        let shared = NodeShared::new(
26181            handle,
26182            "fixture-token".to_owned(),
26183            NodeId::new("node-session-reference-index").unwrap(),
26184            vec![
26185                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26186                    .unwrap(),
26187                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26188            ],
26189            vec![agent("claude"), agent("codex")],
26190        );
26191        let identity = ProviderSessionIdentity {
26192            key: gate4agent_types::ProviderSessionKey::SessionId,
26193            id: "provider-session-42".to_owned(),
26194            transcript_path: None,
26195        };
26196        let index_request = |workspace_id: WorkspaceId,
26197                             provider: AgentId,
26198                             identity: ProviderSessionIdentity,
26199                             display_name: &str| {
26200            NodeRequest::IndexProviderSession {
26201                workspace_id,
26202                provider,
26203                identity,
26204                display_name: display_name.to_owned(),
26205            }
26206        };
26207        let request = index_request(
26208            primary_id.clone(),
26209            agent("claude"),
26210            identity.clone(),
26211            "release shepherd",
26212        );
26213        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26214            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26215        }));
26216        let observer = process_request_inner(
26217            &shared,
26218            77,
26219            ClientRole::Observer,
26220            request.clone(),
26221        )
26222        .await
26223        .unwrap_err();
26224        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26225        let uncontrolled = process_request_inner(
26226            &shared,
26227            77,
26228            ClientRole::Operator,
26229            request.clone(),
26230        )
26231        .await
26232        .unwrap_err();
26233        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26234        shared
26235            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26236            .unwrap();
26237
26238        let indexed = process_request_inner(
26239            &shared,
26240            77,
26241            ClientRole::Operator,
26242            request.clone(),
26243        )
26244        .await
26245        .unwrap();
26246        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26247            panic!("provider session reference index returned another response");
26248        };
26249        assert_eq!(record.state, ManagedSessionState::Dormant);
26250        assert_eq!(record.mode, SessionMode::Pty);
26251        assert_eq!(record.workspace_id, primary_id);
26252        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26253        assert!(record.active_session.is_none());
26254        assert!(record.environment_profile.is_none());
26255        assert!(record.bundle.is_none());
26256        assert!(record.context.is_none());
26257
26258        let repeated = process_request_inner(
26259            &shared,
26260            77,
26261            ClientRole::Operator,
26262            index_request(
26263                primary_id.clone(),
26264                agent("claude"),
26265                identity.clone(),
26266                "ignored replacement",
26267            ),
26268        )
26269        .await
26270        .unwrap();
26271        assert!(matches!(
26272            repeated,
26273            NodeResponse::ProviderSessionIndexed { record: ref existing }
26274                if existing.record_id == record.record_id
26275                    && existing.display_name == "release shepherd"
26276        ));
26277        let conflicting_workspace = process_request_inner(
26278            &shared,
26279            77,
26280            ClientRole::Operator,
26281            index_request(
26282                secondary_id,
26283                agent("claude"),
26284                identity.clone(),
26285                "release shepherd",
26286            ),
26287        )
26288        .await
26289        .unwrap_err();
26290        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26291        let conflicting_provider = process_request_inner(
26292            &shared,
26293            77,
26294            ClientRole::Operator,
26295            index_request(
26296                primary_id.clone(),
26297                agent("codex"),
26298                identity.clone(),
26299                "release shepherd",
26300            ),
26301        )
26302        .await
26303        .unwrap_err();
26304        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26305        let transcript_rejected = process_request_inner(
26306            &shared,
26307            77,
26308            ClientRole::Operator,
26309            index_request(
26310                primary_id,
26311                agent("claude"),
26312                ProviderSessionIdentity {
26313                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26314                    ..identity
26315                },
26316                "release shepherd",
26317            ),
26318        )
26319        .await
26320        .unwrap_err();
26321        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26322        let upserts = shared
26323            .history
26324            .lock()
26325            .unwrap_or_else(|poisoned| poisoned.into_inner())
26326            .events
26327            .iter()
26328            .filter(|event| matches!(
26329                &event.event,
26330                NodeEvent::SessionRecordUpserted { record: published }
26331                    if published.record_id == record.record_id
26332            ))
26333            .count();
26334        assert_eq!(upserts, 1);
26335        std::fs::remove_dir_all(secondary_root).unwrap();
26336    }
26337
26338    #[tokio::test]
26339    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26340        let fixture_root = temporary_workspace_root("standalone-independent");
26341        let target = fixture_root.join("independent");
26342        std::fs::create_dir_all(&fixture_root).unwrap();
26343        let catalog = active_registry().unwrap();
26344        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26345        let selected = WorkspaceConfig::new(
26346            WorkspaceId::new("primary").unwrap(),
26347            std::env::current_dir().unwrap(),
26348        )
26349        .unwrap();
26350        let selected_root = selected.canonical_root().to_owned();
26351        let shared = NodeShared::new(
26352            handle,
26353            "fixture-token".to_owned(),
26354            NodeId::new("node-standalone").unwrap(),
26355            vec![selected],
26356            vec![agent("claude")],
26357        );
26358        let workspace_id = WorkspaceId::new("independent").unwrap();
26359        let request = NodeRequest::CreateStandaloneWorkspace {
26360            workspace_id: workspace_id.clone(),
26361            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26362            initial_branch: Some("main".to_owned()),
26363        };
26364        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26365            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26366        }));
26367        assert!(!request_uses_unnegotiated_capability(
26368            &request,
26369            &baseline_capabilities().unwrap(),
26370        ));
26371        assert!(request_uses_unnegotiated_capability(&request, &[]));
26372
26373        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26374            .await
26375            .unwrap_err();
26376        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26377        assert!(!target.exists());
26378        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26379            .await
26380            .unwrap_err();
26381        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26382        assert!(!target.exists());
26383        shared
26384            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26385            .unwrap();
26386
26387        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26388            .await
26389            .unwrap();
26390        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26391            panic!("standalone workspace creation returned another response");
26392        };
26393        assert_eq!(workspace.workspace_id, workspace_id);
26394        assert!(target.join(".git").is_dir());
26395        let branch = run_git_bounded(
26396            target.to_str().unwrap(),
26397            &["branch", "--show-current"],
26398            GIT_OUTPUT_MAX_BYTES,
26399        )
26400        .await
26401        .unwrap();
26402        assert!(branch.success);
26403        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26404        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26405        assert_eq!(worktrees.len(), 1);
26406        let listed_root = WorkspaceConfig::new(
26407            WorkspaceId::new("listed-independent").unwrap(),
26408            &worktrees[0].path,
26409        )
26410        .unwrap();
26411        assert!(platform::roots_equal(
26412            listed_root.canonical_root(),
26413            windows_path_text(&workspace.canonical_root),
26414        ));
26415        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26416        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26417            snapshot.workspace_id == workspace_id
26418                && snapshot.canonical_root == workspace.canonical_root
26419        }));
26420        assert!(shared
26421            .history
26422            .lock()
26423            .unwrap_or_else(|poisoned| poisoned.into_inner())
26424            .events
26425            .iter()
26426            .any(|event| matches!(
26427                &event.event,
26428                NodeEvent::WorkspaceAdded { workspace }
26429                    if workspace.workspace_id == workspace_id
26430            )));
26431        std::fs::remove_dir_all(fixture_root).unwrap();
26432    }
26433
26434    #[tokio::test]
26435    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26436        let fixture_root = temporary_workspace_root("standalone-compensation");
26437        let created_target = fixture_root.join("created-target");
26438        let existing_target = fixture_root.join("existing-target");
26439        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26440        let invalid_state_path = invalid_state_parent.join("state.json");
26441        std::fs::create_dir_all(&existing_target).unwrap();
26442        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26443        let catalog = active_registry().unwrap();
26444        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26445        let primary = WorkspaceConfig::new(
26446            WorkspaceId::new("primary").unwrap(),
26447            std::env::current_dir().unwrap(),
26448        )
26449        .unwrap();
26450        let mut shared = NodeShared::new(
26451            handle,
26452            "fixture-token".to_owned(),
26453            NodeId::new("node-standalone-compensation").unwrap(),
26454            vec![primary],
26455            vec![agent("claude")],
26456        );
26457        shared.state_path = Some(invalid_state_path);
26458
26459        let created_recovery = shared
26460            .create_standalone_workspace(
26461                WorkspaceId::new("compensated").unwrap(),
26462                created_target.to_string_lossy().into_owned(),
26463                Some("main".to_owned()),
26464            )
26465            .await
26466            .unwrap_err();
26467        assert_eq!(
26468            created_recovery.code,
26469            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26470        );
26471        assert!(created_target.join(".git").is_dir());
26472        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26473            workspace.workspace_id.as_str() != "compensated"
26474        }));
26475
26476        let existing_recovery = shared
26477            .create_standalone_workspace(
26478                WorkspaceId::new("restored").unwrap(),
26479                existing_target.to_string_lossy().into_owned(),
26480                None,
26481            )
26482            .await
26483            .unwrap_err();
26484        assert_eq!(
26485            existing_recovery.code,
26486            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26487        );
26488        assert!(existing_target.is_dir());
26489        assert!(existing_target.join(".git").is_dir());
26490        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26491            workspace.workspace_id.as_str() != "restored"
26492        }));
26493
26494        let registered_empty = fixture_root.join("registered-empty");
26495        std::fs::create_dir(&registered_empty).unwrap();
26496        let catalog = active_registry().unwrap();
26497        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26498        let registered = WorkspaceConfig::new(
26499            WorkspaceId::new("registered-empty").unwrap(),
26500            &registered_empty,
26501        )
26502        .unwrap();
26503        let duplicate_shared = NodeShared::new(
26504            handle,
26505            "fixture-token".to_owned(),
26506            NodeId::new("node-standalone-duplicate-root").unwrap(),
26507            vec![registered],
26508            vec![agent("claude")],
26509        );
26510        let duplicate = duplicate_shared
26511            .create_standalone_workspace(
26512                WorkspaceId::new("duplicate-root").unwrap(),
26513                registered_empty.to_string_lossy().into_owned(),
26514                None,
26515            )
26516            .await
26517            .unwrap_err();
26518        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26519        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26520        std::fs::remove_dir_all(fixture_root).unwrap();
26521    }
26522
26523    #[test]
26524    fn managed_record_id_collision_preserves_the_existing_record() {
26525        let catalog = active_registry().unwrap();
26526        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26527        let workspace = WorkspaceConfig::new(
26528            WorkspaceId::new("primary").unwrap(),
26529            std::env::current_dir().unwrap(),
26530        )
26531        .unwrap();
26532        let shared = NodeShared::new(
26533            handle,
26534            "fixture-token".to_owned(),
26535            NodeId::new("node-1").unwrap(),
26536            vec![workspace.clone()],
26537            vec![agent("claude")],
26538        );
26539        let record_id = SessionRecordId::new("sr-collision").unwrap();
26540        let original = ManagedSessionRecord {
26541            record_id: record_id.clone(),
26542            display_name: "original".to_owned(),
26543            provider: agent("claude"),
26544            mode: SessionMode::Pty,
26545            state: ManagedSessionState::Dormant,
26546            workspace_id: workspace.workspace_id,
26547            canonical_root: opaque_windows_path(workspace.canonical_root),
26548            provider_session: None,
26549            active_session: None,
26550            environment_profile: None,
26551            bundle: None,
26552            context_id: None,
26553            context: None,
26554            exported_context: None,
26555            task_binding: None,
26556            created_at_unix_ms: 1,
26557            updated_at_unix_ms: 1,
26558            last_error: None,
26559        };
26560        shared.insert_record(original.clone()).unwrap();
26561        let mut collision = original.clone();
26562        collision.display_name = "replacement".to_owned();
26563        let error = shared.insert_record(collision).unwrap_err();
26564        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26565        assert_eq!(shared.record(&record_id).unwrap(), original);
26566    }
26567
26568    #[test]
26569    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26570        let catalog = active_registry().unwrap();
26571        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26572        let workspace = WorkspaceConfig::new(
26573            WorkspaceId::new("primary").unwrap(),
26574            std::env::current_dir().unwrap(),
26575        )
26576        .unwrap();
26577        let shared = NodeShared::new(
26578            handle,
26579            "fixture-token".to_owned(),
26580            NodeId::new("node-1").unwrap(),
26581            vec![workspace.clone()],
26582            vec![agent("claude")],
26583        );
26584        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26585        shared
26586            .insert_record(ManagedSessionRecord {
26587                record_id: record_id.clone(),
26588                display_name: "runtime error".to_owned(),
26589                provider: agent("claude"),
26590                mode: SessionMode::Pty,
26591                state: ManagedSessionState::IdentityPending,
26592                workspace_id: workspace.workspace_id,
26593                canonical_root: opaque_windows_path(workspace.canonical_root),
26594                provider_session: None,
26595                active_session: None,
26596                environment_profile: None,
26597                bundle: None,
26598                context_id: None,
26599                context: None,
26600                exported_context: None,
26601                task_binding: None,
26602                created_at_unix_ms: 1,
26603                updated_at_unix_ms: 1,
26604                last_error: None,
26605            })
26606            .unwrap();
26607
26608        shared
26609            .mark_record_error(
26610                &record_id,
26611                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26612            )
26613            .unwrap();
26614        let record = shared.record(&record_id).unwrap();
26615        assert_eq!(record.state, ManagedSessionState::Unavailable);
26616        assert_eq!(
26617            record.last_error.as_deref(),
26618            Some("provider-runtime-failed"),
26619        );
26620        let snapshot = shared.snapshot();
26621        assert_eq!(snapshot.session_records, vec![record]);
26622    }
26623
26624    /// Regression coverage for the "operator sees `deadline` instead of the
26625    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26626    /// able to learn a command's asynchronous kernel rejection (here,
26627    /// specifically classified as `UnsupportedTransport`) exactly once, and
26628    /// must not still find it after that -- proving the fast path this
26629    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26630    /// loop) drains the reason instead of leaving it to be rediscovered (or
26631    /// missed) on a later poll.
26632    #[test]
26633    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26634        let catalog = active_registry().unwrap();
26635        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26636        let workspace = WorkspaceConfig::new(
26637            WorkspaceId::new("primary").unwrap(),
26638            std::env::current_dir().unwrap(),
26639        )
26640        .unwrap();
26641        let shared = NodeShared::new(
26642            handle,
26643            "fixture-token".to_owned(),
26644            NodeId::new("node-1").unwrap(),
26645            vec![workspace],
26646            vec![agent("claude")],
26647        );
26648
26649        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26650
26651        shared.record_rejected_command(
26652            CommandId(1),
26653            "agent 'claude' does not support transport Acp".to_owned(),
26654            true,
26655        );
26656        // An unrelated command_id must not see another command's reason.
26657        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26658
26659        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26660        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26661        assert!(reason.unsupported_transport);
26662
26663        // Consumed: a second read for the same command_id finds nothing.
26664        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26665
26666        // A rejection unrelated to transport support keeps its own
26667        // classification distinct, so the reader can choose the generic
26668        // `BackendOperationFailed` code instead of misreporting
26669        // `UnsupportedTransport`.
26670        shared.record_rejected_command(
26671            CommandId(3),
26672            "backend busy".to_owned(),
26673            false,
26674        );
26675        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26676        assert!(!unclassified.unsupported_transport);
26677    }
26678
26679    #[test]
26680    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26681        let catalog = active_registry().unwrap();
26682        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26683        let workspace = WorkspaceConfig::new(
26684            WorkspaceId::new("primary").unwrap(),
26685            std::env::current_dir().unwrap(),
26686        )
26687        .unwrap();
26688        let shared = NodeShared::new(
26689            handle,
26690            "fixture-token".to_owned(),
26691            NodeId::new("node-identity-policy").unwrap(),
26692            vec![workspace.clone()],
26693            vec![agent("claude")],
26694        );
26695        let address = SessionAddress {
26696            workspace_id: workspace.workspace_id.clone(),
26697            session: SessionKey {
26698                instance_id: AgentInstanceId(90),
26699                generation: SessionGeneration(1),
26700            },
26701        };
26702        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26703        shared
26704            .insert_record(ManagedSessionRecord {
26705                record_id: record_id.clone(),
26706                display_name: "policy denied".to_owned(),
26707                provider: agent("claude"),
26708                mode: SessionMode::Pty,
26709                state: ManagedSessionState::IdentityPending,
26710                workspace_id: workspace.workspace_id,
26711                canonical_root: opaque_windows_path(workspace.canonical_root),
26712                provider_session: None,
26713                active_session: Some(address.clone()),
26714                environment_profile: None,
26715                bundle: None,
26716                context_id: None,
26717                context: None,
26718                exported_context: None,
26719                task_binding: None,
26720                created_at_unix_ms: 1,
26721                updated_at_unix_ms: 1,
26722                last_error: None,
26723            })
26724            .unwrap();
26725        let event = ControlEvent {
26726            sequence: 1,
26727            command_id: None,
26728            instance_id: address.session.instance_id,
26729            generation: address.session.generation,
26730            event: ControlEventKind::ProviderEvent {
26731                sequence: 1,
26732                source: gate4agent_types::ProviderSource {
26733                    family: AdapterFamily::PtySemantic,
26734                    binding: AdapterBinding::new(
26735                        gate4agent_types::AdapterId::new("claude").unwrap(),
26736                        "fixture/v1",
26737                        gate4agent_types::AdapterVerification::SyntheticFixture,
26738                    )
26739                    .unwrap(),
26740                },
26741                source_sequence: 1,
26742                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26743                    identity: gate4agent_types::ProviderSessionIdentity {
26744                        key: gate4agent_types::ProviderSessionKey::SessionId,
26745                        id: "provider-session".to_owned(),
26746                        transcript_path: None,
26747                    },
26748                },
26749            },
26750        };
26751
26752        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26753
26754        let record = shared.record(&record_id).unwrap();
26755        assert!(record.provider_session.is_none());
26756        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26757        assert_eq!(record.active_session.as_ref(), Some(&address));
26758    }
26759
26760    #[test]
26761    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26762        let secondary_root = temporary_workspace_root("identity-scope");
26763        std::fs::create_dir_all(&secondary_root).unwrap();
26764        let catalog = active_registry().unwrap();
26765        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26766        let primary = WorkspaceConfig::new(
26767            WorkspaceId::new("primary").unwrap(),
26768            std::env::current_dir().unwrap(),
26769        )
26770        .unwrap();
26771        let secondary = WorkspaceConfig::new(
26772            WorkspaceId::new("secondary").unwrap(),
26773            &secondary_root,
26774        )
26775        .unwrap();
26776        let shared = NodeShared::new(
26777            handle,
26778            "fixture-token".to_owned(),
26779            NodeId::new("node-1").unwrap(),
26780            vec![primary.clone(), secondary.clone()],
26781            vec![agent("claude")],
26782        );
26783        let identity = gate4agent_types::ProviderSessionIdentity {
26784            key: gate4agent_types::ProviderSessionKey::SessionId,
26785            id: "provider-session".to_owned(),
26786            transcript_path: None,
26787        };
26788        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26789        shared
26790            .insert_record(ManagedSessionRecord {
26791                record_id: dormant_id.clone(),
26792                display_name: "dormant".to_owned(),
26793                provider: agent("claude"),
26794                mode: SessionMode::Pty,
26795                state: ManagedSessionState::Dormant,
26796                workspace_id: primary.workspace_id,
26797                canonical_root: opaque_windows_path(primary.canonical_root),
26798                provider_session: Some(identity.clone()),
26799                active_session: None,
26800                environment_profile: None,
26801                bundle: None,
26802                context_id: None,
26803                context: None,
26804                exported_context: None,
26805                task_binding: None,
26806                created_at_unix_ms: 1,
26807                updated_at_unix_ms: 1,
26808                last_error: None,
26809            })
26810            .unwrap();
26811        let address = SessionAddress {
26812            workspace_id: secondary.workspace_id.clone(),
26813            session: SessionKey {
26814                instance_id: AgentInstanceId(91),
26815                generation: SessionGeneration(1),
26816            },
26817        };
26818        let current_id = SessionRecordId::new("sr-current").unwrap();
26819        shared
26820            .insert_record(ManagedSessionRecord {
26821                record_id: current_id.clone(),
26822                display_name: "current".to_owned(),
26823                provider: agent("claude"),
26824                mode: SessionMode::Pty,
26825                state: ManagedSessionState::IdentityPending,
26826                workspace_id: secondary.workspace_id,
26827                canonical_root: opaque_windows_path(secondary.canonical_root),
26828                provider_session: None,
26829                active_session: Some(address.clone()),
26830                environment_profile: None,
26831                bundle: None,
26832                context_id: None,
26833                context: None,
26834                exported_context: None,
26835                task_binding: None,
26836                created_at_unix_ms: 2,
26837                updated_at_unix_ms: 2,
26838                last_error: None,
26839            })
26840            .unwrap();
26841        shared.bind_managed_session(
26842            &address,
26843            current_id.clone(),
26844            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26845            None,
26846        );
26847        let event = ControlEvent {
26848            sequence: 1,
26849            command_id: None,
26850            instance_id: address.session.instance_id,
26851            generation: address.session.generation,
26852            event: ControlEventKind::ProviderEvent {
26853                sequence: 1,
26854                source: gate4agent_types::ProviderSource {
26855                    family: gate4agent_types::AdapterFamily::PtySemantic,
26856                    binding: gate4agent_types::AdapterBinding::new(
26857                        gate4agent_types::AdapterId::new("claude").unwrap(),
26858                        "fixture/v1",
26859                        gate4agent_types::AdapterVerification::SyntheticFixture,
26860                    )
26861                    .unwrap(),
26862                },
26863                source_sequence: 1,
26864                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26865                    identity,
26866                },
26867            },
26868        };
26869        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26870
26871        let binding = shared
26872            .session_bindings
26873            .lock()
26874            .unwrap_or_else(|poisoned| poisoned.into_inner())
26875            .get(&address.session.instance_id)
26876            .cloned()
26877            .unwrap();
26878        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26879        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26880        let current = shared.record(&current_id).unwrap();
26881        assert_eq!(current.state, ManagedSessionState::Unavailable);
26882        assert_eq!(current.active_session.as_ref(), Some(&address));
26883        assert_eq!(
26884            current.last_error.as_deref(),
26885            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26886        );
26887        std::fs::remove_dir_all(secondary_root).unwrap();
26888    }
26889
26890    #[tokio::test]
26891    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26892        let root = temporary_workspace_root("semantic-provider-session");
26893        std::fs::create_dir_all(&root).unwrap();
26894        let state_path = root.join("state-v1.json");
26895        let catalog = active_registry().unwrap();
26896        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26897        let workspace = WorkspaceConfig::new(
26898            WorkspaceId::new("primary").unwrap(),
26899            &root,
26900        )
26901        .unwrap();
26902        let node_id = NodeId::new("node-semantic-identity").unwrap();
26903        let shared = NodeShared::new_with_incarnation(
26904            handle,
26905            "fixture-token".to_owned(),
26906            node_id.clone(),
26907            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26908            vec![workspace.clone()],
26909            vec![agent("claude")],
26910            ProviderRuntimeStatuses::default(),
26911            None,
26912            Vec::new(),
26913            Vec::new(),
26914            SpawnProfileRegistry::default(),
26915            None,
26916            None,
26917            None,
26918            Some(state_path.clone()),
26919            Vec::new(),
26920            Vec::new(),
26921            Vec::new(),
26922            Vec::new(),
26923            None,
26924            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26925        );
26926        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26927            key: gate4agent_types::ProviderSessionKey::SessionId,
26928            id: "provider-session".to_owned(),
26929            transcript_path: None,
26930        };
26931        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26932        shared
26933            .insert_record(ManagedSessionRecord {
26934                record_id: durable_id.clone(),
26935                display_name: "durable".to_owned(),
26936                provider: agent("claude"),
26937                mode: SessionMode::Pty,
26938                state: ManagedSessionState::Dormant,
26939                workspace_id: workspace.workspace_id.clone(),
26940                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26941                provider_session: Some(persisted_identity.clone()),
26942                active_session: None,
26943                environment_profile: None,
26944                bundle: None,
26945                context_id: None,
26946                context: None,
26947                exported_context: None,
26948                task_binding: None,
26949                created_at_unix_ms: 1,
26950                updated_at_unix_ms: 1,
26951                last_error: None,
26952            })
26953            .unwrap();
26954        let address = SessionAddress {
26955            workspace_id: workspace.workspace_id.clone(),
26956            session: SessionKey {
26957                instance_id: AgentInstanceId(92),
26958                generation: SessionGeneration(2),
26959            },
26960        };
26961        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26962        shared
26963            .insert_record(ManagedSessionRecord {
26964                record_id: pending_id.clone(),
26965                display_name: "pending".to_owned(),
26966                provider: agent("claude"),
26967                mode: SessionMode::Pty,
26968                state: ManagedSessionState::IdentityPending,
26969                workspace_id: workspace.workspace_id.clone(),
26970                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26971                provider_session: None,
26972                active_session: Some(address.clone()),
26973                environment_profile: None,
26974                bundle: None,
26975                context_id: None,
26976                context: None,
26977                exported_context: None,
26978                task_binding: None,
26979                created_at_unix_ms: 2,
26980                updated_at_unix_ms: 2,
26981                last_error: None,
26982            })
26983            .unwrap();
26984        shared.bind_managed_session(
26985            &address,
26986            pending_id.clone(),
26987            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26988            None,
26989        );
26990        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26991            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26992            ..persisted_identity
26993        };
26994        let event = ControlEvent {
26995            sequence: 1,
26996            command_id: None,
26997            instance_id: address.session.instance_id,
26998            generation: address.session.generation,
26999            event: ControlEventKind::ProviderEvent {
27000                sequence: 1,
27001                source: gate4agent_types::ProviderSource {
27002                    family: gate4agent_types::AdapterFamily::PtySemantic,
27003                    binding: gate4agent_types::AdapterBinding::new(
27004                        gate4agent_types::AdapterId::new("claude").unwrap(),
27005                        "fixture/v1",
27006                        gate4agent_types::AdapterVerification::SyntheticFixture,
27007                    )
27008                    .unwrap(),
27009                },
27010                source_sequence: 1,
27011                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27012                    identity: observed_identity.clone(),
27013                },
27014            },
27015        };
27016        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27017
27018        let snapshot = shared.snapshot();
27019        assert_eq!(snapshot.session_records.len(), 1);
27020        let live = &snapshot.session_records[0];
27021        assert_eq!(live.record_id, durable_id);
27022        assert_eq!(live.state, ManagedSessionState::Live);
27023        assert_eq!(live.active_session.as_ref(), Some(&address));
27024        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27025        let binding = shared
27026            .session_bindings
27027            .lock()
27028            .unwrap_or_else(|poisoned| poisoned.into_inner())
27029            .get(&address.session.instance_id)
27030            .cloned()
27031            .unwrap();
27032        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27033
27034        let mut duplicate = live.clone();
27035        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27036        duplicate.display_name = "duplicate".to_owned();
27037        duplicate.state = ManagedSessionState::Dormant;
27038        duplicate.active_session = None;
27039        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27040        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27041        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27042        assert_eq!(shared.snapshot().session_records.len(), 1);
27043
27044        let terminal_size = gate4agent_types::TerminalSize {
27045            rows: 24,
27046            columns: 80,
27047        };
27048        let (first_busy, second_busy) = tokio::join!(
27049            shared.resume_session_record(&durable_id, terminal_size, None),
27050            shared.resume_session_record(&durable_id, terminal_size, None),
27051        );
27052        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27053            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27054            assert_eq!(busy.message, "session-record-busy");
27055        }
27056
27057        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27058        assert_eq!(loaded.records.len(), 1);
27059        assert_eq!(loaded.records[0].record_id, durable_id);
27060        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27061        assert_eq!(
27062            loaded.records[0]
27063                .provider_session
27064                .as_ref()
27065                .unwrap()
27066                .transcript_path,
27067            None,
27068        );
27069        let catalog = active_registry().unwrap();
27070        let (reloaded_handle, _reloaded_runtime) =
27071            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27072        let reloaded = NodeShared::new_with_incarnation(
27073            reloaded_handle,
27074            "fixture-token".to_owned(),
27075            node_id,
27076            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27077            vec![workspace],
27078            vec![agent("claude")],
27079            ProviderRuntimeStatuses::default(),
27080            None,
27081            Vec::new(),
27082            Vec::new(),
27083            SpawnProfileRegistry::default(),
27084            None,
27085            None,
27086            None,
27087            None,
27088            loaded.records,
27089            loaded.managed_worktrees,
27090            loaded.managed_worktree_tombstones,
27091            loaded.materializations,
27092            loaded.warning,
27093            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27094        );
27095        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27096        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27097        std::fs::remove_dir_all(root).unwrap();
27098    }
27099
27100    #[tokio::test]
27101    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27102        let secondary_root = temporary_workspace_root("workspace-restore");
27103        let other_root = temporary_workspace_root("workspace-conflict");
27104        std::fs::create_dir_all(&secondary_root).unwrap();
27105        std::fs::create_dir_all(&other_root).unwrap();
27106        let catalog = active_registry().unwrap();
27107        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27108        let primary = WorkspaceConfig::new(
27109            WorkspaceId::new("primary").unwrap(),
27110            std::env::current_dir().unwrap(),
27111        )
27112        .unwrap();
27113        let secondary = WorkspaceConfig::new(
27114            WorkspaceId::new("secondary").unwrap(),
27115            &secondary_root,
27116        )
27117        .unwrap();
27118        let shared = NodeShared::new(
27119            handle,
27120            "fixture-token".to_owned(),
27121            NodeId::new("node-1").unwrap(),
27122            vec![primary, secondary.clone()],
27123            vec![agent("claude")],
27124        );
27125        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27126        shared
27127            .insert_record(ManagedSessionRecord {
27128                record_id: record_id.clone(),
27129                display_name: "workspace record".to_owned(),
27130                provider: agent("claude"),
27131                mode: SessionMode::Pty,
27132                state: ManagedSessionState::Dormant,
27133                workspace_id: secondary.workspace_id.clone(),
27134                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27135                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27136                    key: gate4agent_types::ProviderSessionKey::SessionId,
27137                    id: "provider-session".to_owned(),
27138                    transcript_path: None,
27139                }),
27140                active_session: None,
27141                environment_profile: None,
27142                bundle: None,
27143                context_id: None,
27144                context: None,
27145                exported_context: None,
27146                task_binding: None,
27147                created_at_unix_ms: 1,
27148                updated_at_unix_ms: 1,
27149                last_error: None,
27150            })
27151            .unwrap();
27152        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27153        let unavailable = shared.record(&record_id).unwrap();
27154        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27155        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27156
27157        let conflict = shared
27158            .register_workspace(
27159                secondary.workspace_id.clone(),
27160                other_root.to_string_lossy().into_owned(),
27161            )
27162            .await
27163            .unwrap_err();
27164        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27165        shared
27166            .register_workspace(
27167                secondary.workspace_id,
27168                secondary_root.to_string_lossy().into_owned(),
27169            )
27170            .await
27171            .unwrap();
27172        let restored = shared.record(&record_id).unwrap();
27173        assert_eq!(restored.state, ManagedSessionState::Dormant);
27174        assert!(restored.last_error.is_none());
27175        std::fs::remove_dir_all(secondary_root).unwrap();
27176        std::fs::remove_dir_all(other_root).unwrap();
27177    }
27178
27179    #[test]
27180    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27181        let error = validate_workspace_request_root(
27182            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27183        )
27184        .unwrap_err();
27185        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27186        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27187        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27188    }
27189
27190    #[test]
27191    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27192        let catalog = active_registry().unwrap();
27193        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27194        let workspace = WorkspaceConfig::new(
27195            WorkspaceId::new("primary").unwrap(),
27196            std::env::current_dir().unwrap(),
27197        )
27198        .unwrap();
27199        let shared = NodeShared::new(
27200            handle,
27201            "fixture-token".to_owned(),
27202            NodeId::new("node-1").unwrap(),
27203            vec![workspace],
27204            vec![agent("claude")],
27205        );
27206        let publishers = 4_u64;
27207        let events_per_publisher = 256_u64;
27208        let start = std::sync::Barrier::new(publishers as usize + 1);
27209        let finished = AtomicU64::new(0);
27210
27211        std::thread::scope(|scope| {
27212            for _ in 0..publishers {
27213                scope.spawn(|| {
27214                    start.wait();
27215                    for _ in 0..events_per_publisher {
27216                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27217                    }
27218                    finished.fetch_add(1, Ordering::Release);
27219                });
27220            }
27221            start.wait();
27222            while finished.load(Ordering::Acquire) != publishers {
27223                assert_resync_history_is_linearized(shared.resync(0));
27224                std::thread::yield_now();
27225            }
27226        });
27227        let response = shared.resync(0);
27228        assert_resync_history_is_linearized(response.clone());
27229        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27230            unreachable!("resync helper returned another response");
27231        };
27232        assert_eq!(event_sequence, publishers * events_per_publisher);
27233        assert_eq!(events.len() as u64, event_sequence);
27234    }
27235
27236    fn assert_resync_history_is_linearized(response: NodeResponse) {
27237        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27238            unreachable!("resync helper returned another response");
27239        };
27240        assert!(events.windows(2).all(|pair| {
27241            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27242        }));
27243        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27244    }
27245
27246    #[test]
27247    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27248        assert_eq!(
27249            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27250            r"C:\repo\worktree",
27251        );
27252        assert_eq!(
27253            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27254            r"\\server\share\repo",
27255        );
27256        assert!(validate_workspace_root(
27257            &WorkspaceId::new("network").unwrap(),
27258            r"\\server\share\repo",
27259        )
27260        .is_err());
27261        let workspace = WorkspaceConfig::new(
27262            WorkspaceId::new("primary").unwrap(),
27263            std::env::current_dir().unwrap(),
27264        )
27265        .unwrap();
27266        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27267    }
27268
27269    #[test]
27270    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27271        let catalog = active_registry().unwrap();
27272        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27273        let workspace_id = WorkspaceId::new("primary").unwrap();
27274        let workspace = WorkspaceConfig::new(
27275            workspace_id.clone(),
27276            std::env::current_dir().unwrap(),
27277        )
27278        .unwrap();
27279        let shared = NodeShared::new(
27280            handle,
27281            "fixture-token".to_owned(),
27282            NodeId::new("node-1").unwrap(),
27283            vec![workspace],
27284            vec![agent("claude")],
27285        );
27286        let instance_id = AgentInstanceId(17);
27287        let original = SessionAddress {
27288            workspace_id,
27289            session: SessionKey {
27290                instance_id,
27291                generation: SessionGeneration(3),
27292            },
27293        };
27294        shared.bind_session(
27295            instance_id,
27296            original.workspace_id.clone(),
27297            original.session.generation,
27298        );
27299        shared
27300            .arm_resume(
27301                &original,
27302                CommandId(41),
27303                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27304            )
27305            .unwrap();
27306
27307        shared.publish_control(ControlEvent {
27308            sequence: 1,
27309            command_id: None,
27310            instance_id,
27311            generation: SessionGeneration(4),
27312            event: ControlEventKind::Running { process_id: None },
27313        });
27314        assert_eq!(
27315            shared.address_for(instance_id, SessionGeneration(3)),
27316            Some(original.clone()),
27317        );
27318        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27319
27320        shared.publish_control(ControlEvent {
27321            sequence: 2,
27322            command_id: Some(CommandId(42)),
27323            instance_id,
27324            generation: SessionGeneration(3),
27325            event: ControlEventKind::CommandRejected {
27326                message: "unrelated command".to_owned(),
27327            },
27328        });
27329
27330        shared.publish_control(ControlEvent {
27331            sequence: 3,
27332            command_id: None,
27333            instance_id,
27334            generation: SessionGeneration(4),
27335            event: ControlEventKind::ResumeAuthorized {
27336                session: gate4agent_types::ResumeSessionSummary {
27337                    key: gate4agent_types::ProviderSessionKey::SessionId,
27338                    id: "fixture-session".to_owned(),
27339                },
27340            },
27341        });
27342        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27343        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27344        assert_eq!(rebound.workspace_id, original.workspace_id);
27345        let history = shared
27346            .history
27347            .lock()
27348            .unwrap_or_else(|poisoned| poisoned.into_inner());
27349        assert!(history.events.iter().any(|envelope| matches!(
27350            &envelope.event,
27351            NodeEvent::Control { address, event }
27352                if address == &rebound
27353                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27354        )));
27355    }
27356
27357    #[test]
27358    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27359        for agent in ["claude", "codex", "kimi"] {
27360            assert_eq!(
27361                prompt_framing(&AgentId::new(agent).unwrap()),
27362                PromptFraming::BracketedPaste
27363            );
27364        }
27365    }
27366
27367    fn generous_walk_deadline() -> Instant {
27368        Instant::now() + Duration::from_secs(30)
27369    }
27370
27371    #[test]
27372    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27373        let root = temporary_workspace_root("tree");
27374        std::fs::create_dir_all(root.join("src")).unwrap();
27375        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27376        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27377        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27378        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27379        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27380        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27381        // real repository's own build layout) that carries the Cache
27382        // Directory Tagging Specification marker cargo writes into every
27383        // `--target-dir` it creates: must be skipped even though its name
27384        // does not exactly match `target`.
27385        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27386        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27387        // A directory that merely *starts with* `target` but carries no
27388        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27389        // on the name alone, so this stays in the response.
27390        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27391        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27392
27393        let (entries, truncated, _budget) =
27394            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27395        assert!(!truncated);
27396        assert!(entries.iter().any(|entry| {
27397            entry.relative_path.as_utf8() == Some("src")
27398                && entry.kind == WorkspaceEntryKind::Directory
27399        }));
27400        assert!(entries.iter().any(|entry| {
27401            entry.relative_path.as_utf8() == Some("src/lib.rs")
27402                && entry.kind == WorkspaceEntryKind::File
27403        }));
27404        assert!(entries.iter().any(|entry| {
27405            entry.relative_path.as_utf8() == Some("target-notes")
27406                && entry.kind == WorkspaceEntryKind::Directory
27407        }));
27408        assert!(entries.iter().any(|entry| {
27409            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27410                && entry.kind == WorkspaceEntryKind::File
27411        }));
27412        assert!(entries.iter().all(|entry| {
27413            let path = entry.relative_path.as_utf8().unwrap();
27414            !path.starts_with(".git")
27415                && !(path == "target" || path.starts_with("target/"))
27416                && !(path == "target-a2" || path.starts_with("target-a2/"))
27417                && !path.starts_with("node_modules")
27418        }));
27419
27420        std::fs::remove_dir_all(&root).unwrap();
27421    }
27422
27423    #[test]
27424    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27425        let root = temporary_workspace_root("tree-entry-cap");
27426        std::fs::create_dir_all(&root).unwrap();
27427        for index in 0..20 {
27428            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27429        }
27430
27431        let (entries, truncated, budget) =
27432            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27433        assert!(truncated);
27434        assert!(budget.entry_cap_exceeded);
27435        assert!(!budget.time_budget_exceeded);
27436        assert!(entries.len() <= 5);
27437
27438        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27439            .expect("an entry-cap-truncated walk must report a truncation marker");
27440        assert!(inspection_truncation.walk_entry_cap_exceeded);
27441        assert!(!inspection_truncation.walk_time_budget_exceeded);
27442        assert!(!inspection_truncation.git_time_budget_exceeded);
27443        assert!(inspection_truncation.entries_visited >= 5);
27444
27445        std::fs::remove_dir_all(&root).unwrap();
27446    }
27447
27448    #[test]
27449    fn workspace_walk_within_budget_reports_no_truncation() {
27450        let root = temporary_workspace_root("tree-no-truncation");
27451        std::fs::create_dir_all(&root).unwrap();
27452        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27453
27454        let (_entries, truncated, budget) =
27455            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27456        assert!(!truncated);
27457        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27458
27459        std::fs::remove_dir_all(&root).unwrap();
27460    }
27461
27462    #[test]
27463    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27464        let root = temporary_workspace_root("tree-prefix-siblings");
27465        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27466        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27467        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27468        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27469
27470        let (entries, truncated, _budget) =
27471            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27472        assert!(!truncated);
27473        let paths = entries
27474            .iter()
27475            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27476            .collect::<Vec<_>>();
27477        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27478        assert_eq!(
27479            paths,
27480            vec![
27481                "gate4agent-c2".to_owned(),
27482                "gate4agent-c2-client".to_owned(),
27483                "gate4agent-c2-client/lib.rs".to_owned(),
27484                "gate4agent-c2/src".to_owned(),
27485                "gate4agent-c2/src/lib.rs".to_owned(),
27486            ],
27487        );
27488
27489        std::fs::remove_dir_all(&root).unwrap();
27490    }
27491
27492    #[test]
27493    fn git_parsers_cap_status_and_commit_results() {
27494        let mut status_output = Vec::new();
27495        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27496            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27497        }
27498        let mut snapshot = git_snapshot_for_parser();
27499        parse_git_status(&status_output, &mut snapshot);
27500        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27501        assert!(snapshot.truncated);
27502        assert_eq!(snapshot.status[0].index_status, " ");
27503        assert_eq!(snapshot.status[0].worktree_status, "M");
27504
27505        snapshot.truncated = false;
27506        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27507            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27508            .collect::<String>();
27509        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27510        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27511        assert!(snapshot.truncated);
27512    }
27513
27514    #[test]
27515    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27516        let mut snapshot = git_snapshot_for_parser();
27517        parse_git_status(
27518            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27519            &mut snapshot,
27520        );
27521
27522        assert!(!snapshot.truncated);
27523        assert_eq!(snapshot.status.len(), 3);
27524        assert_eq!(snapshot.status[0].index_status, "R");
27525        assert_eq!(snapshot.status[0].worktree_status, " ");
27526        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27527        assert_eq!(
27528            snapshot.status[0]
27529                .previous_path
27530                .as_ref()
27531                .and_then(RepositoryPath::as_utf8),
27532            Some("src/previous name.rs"),
27533        );
27534        assert_eq!(snapshot.status[1].index_status, " ");
27535        assert_eq!(snapshot.status[1].worktree_status, "C");
27536        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27537        assert_eq!(
27538            snapshot.status[1]
27539                .previous_path
27540                .as_ref()
27541                .and_then(RepositoryPath::as_utf8),
27542            Some("src/original.rs"),
27543        );
27544        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27545        assert!(snapshot.status[2].previous_path.is_none());
27546    }
27547
27548    #[test]
27549    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27550        let mut snapshot = git_snapshot_for_parser();
27551        parse_git_status(
27552            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27553            &mut snapshot,
27554        );
27555
27556        assert!(snapshot.truncated);
27557        assert_eq!(snapshot.status.len(), 1);
27558        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27559        assert!(snapshot.status[0].previous_path.is_none());
27560    }
27561
27562    #[tokio::test]
27563    async fn observer_can_browse_host_directories_without_controller() {
27564        let root = temporary_workspace_root("host-directory-observer");
27565        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27566        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27567        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27568        let workspace_id = WorkspaceId::new("primary").unwrap();
27569        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27570        let catalog = active_registry().unwrap();
27571        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27572        let shared = NodeShared::new(
27573            handle,
27574            "fixture-token".to_owned(),
27575            NodeId::new("node-1").unwrap(),
27576            vec![workspace],
27577            vec![agent("claude")],
27578        );
27579        let request = NodeRequest::BrowseHostDirectories {
27580            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27581            after: None,
27582        };
27583        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27584            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27585        }));
27586        assert!(!request_uses_unnegotiated_capability(
27587            &request,
27588            &baseline_capabilities().unwrap(),
27589        ));
27590        assert!(request_uses_unnegotiated_capability(&request, &[]));
27591
27592        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27593            .await
27594            .unwrap();
27595        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27596            panic!("host directory browse returned another response");
27597        };
27598        assert_eq!(
27599            listing
27600                .entries
27601                .iter()
27602                .map(|entry| entry.display_name.as_str())
27603                .collect::<Vec<_>>(),
27604            vec!["directory-a", "directory-b"],
27605        );
27606        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27607        assert!(!listing.incomplete);
27608        std::fs::remove_dir_all(root).unwrap();
27609    }
27610
27611    #[tokio::test]
27612    async fn observer_can_inspect_only_a_registered_workspace() {
27613        let root = temporary_workspace_root("observer");
27614        std::fs::create_dir_all(root.join("src")).unwrap();
27615        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27616        let workspace_id = WorkspaceId::new("primary").unwrap();
27617        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27618        let catalog = active_registry().unwrap();
27619        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27620        let shared = NodeShared::new(
27621            handle,
27622            "fixture-token".to_owned(),
27623            NodeId::new("node-1").unwrap(),
27624            vec![workspace],
27625            vec![agent("claude")],
27626        );
27627
27628        let response = process_request_inner(
27629            &shared,
27630            77,
27631            ClientRole::Observer,
27632            NodeRequest::InspectWorkspace {
27633                workspace_id: workspace_id.clone(),
27634            },
27635        )
27636        .await
27637        .unwrap();
27638        let NodeResponse::WorkspaceInspected { inspection } = response else {
27639            panic!("workspace inspection returned another response");
27640        };
27641        assert_eq!(inspection.workspace_id, workspace_id);
27642        assert!(inspection.entries.iter().any(|entry| {
27643            entry.relative_path.as_utf8() == Some("src/main.rs")
27644        }));
27645
27646        let unknown = process_request_inner(
27647            &shared,
27648            77,
27649            ClientRole::Observer,
27650            NodeRequest::InspectWorkspace {
27651                workspace_id: WorkspaceId::new("unknown").unwrap(),
27652            },
27653        )
27654        .await
27655        .unwrap_err();
27656        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27657        std::fs::remove_dir_all(&root).unwrap();
27658    }
27659
27660    #[test]
27661    fn context_pack_source_accepts_only_running_or_successful_exit() {
27662        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27663        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27664            exit_code: Some(0),
27665        }));
27666        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27667            exit_code: Some(1),
27668        }));
27669        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27670            exit_code: None,
27671        }));
27672        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27673            message: "provider failed".to_owned(),
27674        }));
27675        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27676        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27677        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27678    }
27679
27680    #[test]
27681    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27682        let git = git_snapshot_for_parser();
27683        let first = ContextPackRepositoryObservation {
27684            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27685            git: git.clone(),
27686            files: Vec::new(),
27687        };
27688        let second = ContextPackRepositoryObservation {
27689            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27690            git,
27691            files: Vec::new(),
27692        };
27693
27694        let error = stable_context_pack_repository(first, second).unwrap_err();
27695
27696        assert_eq!(
27697            error.code,
27698            NodeFailureCode::ContextPackMaterializationFailed,
27699        );
27700    }
27701
27702    #[test]
27703    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27704        let git = git_snapshot_for_parser();
27705        let first = ContextPackRepositoryObservation {
27706            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27707            git: git.clone(),
27708            files: vec![ContextPackRepositoryFileSource::utf8(
27709                "README.md",
27710                "first\n".to_owned(),
27711                6,
27712            )],
27713        };
27714        let second = ContextPackRepositoryObservation {
27715            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27716            git,
27717            files: vec![ContextPackRepositoryFileSource::utf8(
27718                "README.md",
27719                "other\n".to_owned(),
27720                6,
27721            )],
27722        };
27723
27724        let error = stable_context_pack_repository(first, second).unwrap_err();
27725
27726        assert_eq!(
27727            error.code,
27728            NodeFailureCode::ContextPackMaterializationFailed,
27729        );
27730    }
27731
27732    #[tokio::test]
27733    async fn native_session_catalog_zero_active_session_fixture() {
27734        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27735        let history_root = temporary_workspace_root("native-catalog-history");
27736        let outside_root = temporary_workspace_root("native-catalog-outside");
27737        std::fs::create_dir_all(&workspace_root).unwrap();
27738        std::fs::create_dir_all(&history_root).unwrap();
27739        std::fs::create_dir_all(&outside_root).unwrap();
27740        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27741            [
27742                serde_json::json!({
27743                    "type": "user",
27744                    "sessionId": session_id,
27745                    "cwd": cwd.to_str().unwrap(),
27746                    "message": { "content": "raw user text must not cross the catalog wire" }
27747                }),
27748                serde_json::json!({
27749                    "type": "assistant",
27750                    "sessionId": session_id,
27751                    "cwd": cwd.to_str().unwrap(),
27752                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27753                }),
27754                serde_json::json!({
27755                    "type": "ai-title",
27756                    "sessionId": session_id,
27757                    "aiTitle": title
27758                }),
27759            ]
27760            .into_iter()
27761            .map(|record| record.to_string())
27762            .collect::<Vec<_>>()
27763            .join("\n")
27764        };
27765        std::fs::write(
27766            history_root.join("stable-session.jsonl"),
27767            transcript("stable-session", &workspace_root, "Workspace review"),
27768        )
27769        .unwrap();
27770        std::fs::write(
27771            history_root.join("outside-session.jsonl"),
27772            transcript("outside-session", &outside_root, "Outside review"),
27773        )
27774        .unwrap();
27775        let history = NativeHistoryConfig::new(vec![
27776            NativeHistoryRoot::new(
27777                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27778                HistorySourceLayout::SingleNdjson,
27779                history_root.clone(),
27780            )
27781            .unwrap(),
27782        ])
27783        .unwrap();
27784        let catalog = active_registry().unwrap();
27785        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27786        assert!(handle.snapshot().sessions.is_empty());
27787        let workspace_id = WorkspaceId::new("primary").unwrap();
27788        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27789        let mut shared = NodeShared::new(
27790            handle,
27791            "fixture-token".to_owned(),
27792            NodeId::new("node-native-catalog").unwrap(),
27793            vec![workspace],
27794            vec![agent("claude")],
27795        );
27796        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27797            NativeSessionCatalogAuthority::new(history),
27798        )));
27799        let route = NativeSessionCatalogRoute::workspace(
27800            workspace_id.clone(),
27801            agent("claude"),
27802        );
27803        let request = NodeRequest::CatalogNativeSessions {
27804            route: route.clone(),
27805            limit: 2,
27806        };
27807        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27808            .await
27809            .unwrap_err();
27810        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27811        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27812            .await
27813            .unwrap();
27814        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27815            panic!("native catalog returned a different response");
27816        };
27817        let summary = summary.expect("node must emit native session catalog summary metadata");
27818        assert_eq!(entries.len(), 1);
27819        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27820        assert_eq!(entries[0].external_group, None);
27821        assert_eq!(entries[0].record_id, None);
27822        assert_eq!(entries[0].title, None);
27823        assert_eq!(entries[0].model, None);
27824        assert_eq!(entries[0].message_count, 0);
27825        let encoded_entries = serde_json::to_string(&entries).unwrap();
27826        assert!(!encoded_entries.contains("stable-session"));
27827        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27828        let page_request = NodeRequest::PageNativeSessions {
27829            route: route.clone(),
27830            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27831            catalog_revision: summary.catalog_revision,
27832            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27833            after_selection_id: None,
27834            limit: 2,
27835        };
27836        let observer = process_request_inner(
27837            &shared,
27838            7,
27839            ClientRole::Observer,
27840            page_request.clone(),
27841        )
27842        .await
27843        .unwrap_err();
27844        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27845        let page_response = process_request_inner(
27846            &shared,
27847            7,
27848            ClientRole::Operator,
27849            page_request.clone(),
27850        )
27851        .await
27852        .unwrap();
27853        assert!(matches!(
27854            page_response,
27855            NodeResponse::NativeSessionsPaged { page, .. }
27856                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27857        ));
27858        let stale = process_request_inner(
27859            &shared,
27860            7,
27861            ClientRole::Operator,
27862            NodeRequest::PageNativeSessions {
27863                route: route.clone(),
27864                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27865                catalog_revision: summary.catalog_revision.wrapping_add(1),
27866                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27867                after_selection_id: None,
27868                limit: 2,
27869            },
27870        )
27871        .await
27872        .unwrap_err();
27873        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27874        let selection = NativeSessionSelection {
27875            route: route.clone(),
27876            catalog_revision: summary.catalog_revision,
27877            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27878            selection_id: entries[0].selection_id.clone(),
27879        };
27880        let preview_response = process_request_inner(
27881            &shared,
27882            7,
27883            ClientRole::Operator,
27884            NodeRequest::PreviewNativeSession {
27885                selection: selection.clone(),
27886                message_limit: 1,
27887            },
27888        )
27889        .await
27890        .unwrap();
27891        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27892            panic!("native preview returned a different response");
27893        };
27894        assert_eq!(preview.messages.len(), 1);
27895        assert!(preview.message_count_exact);
27896        assert_eq!(preview.messages[0].text, "raw answer");
27897        assert!(preview.truncated);
27898
27899        let index_request = NodeRequest::IndexNativeSession {
27900            selection: selection.clone(),
27901            display_name: "Saved Claude".to_owned(),
27902        };
27903        let observer = process_request_inner(
27904            &shared,
27905            7,
27906            ClientRole::Observer,
27907            index_request.clone(),
27908        )
27909        .await
27910        .unwrap_err();
27911        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27912        let without_controller = process_request_inner(
27913            &shared,
27914            7,
27915            ClientRole::Operator,
27916            index_request.clone(),
27917        )
27918        .await
27919        .unwrap_err();
27920        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27921        let acquired = process_request_inner(
27922            &shared,
27923            7,
27924            ClientRole::Operator,
27925            NodeRequest::AcquireController { lease_ms: 5_000 },
27926        )
27927        .await
27928        .unwrap();
27929        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27930        let indexed = process_request_inner(
27931            &shared,
27932            7,
27933            ClientRole::Operator,
27934            index_request,
27935        )
27936        .await
27937        .unwrap();
27938        assert!(indexed.native_session_index_contract_is_valid());
27939        let NodeResponse::NativeSessionIndexed {
27940            selection: echoed_selection,
27941            record,
27942        } = indexed else {
27943            panic!("native session index returned a different response");
27944        };
27945        assert_eq!(echoed_selection, selection);
27946        let recataloged = process_request_inner(
27947            &shared,
27948            7,
27949            ClientRole::Operator,
27950            NodeRequest::CatalogNativeSessions {
27951                route,
27952                limit: 2,
27953            },
27954        )
27955        .await
27956        .unwrap();
27957        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27958            panic!("native catalog returned a different response after indexing");
27959        };
27960        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27961
27962        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27963        let external_catalog = process_request_inner(
27964            &shared,
27965            7,
27966            ClientRole::Operator,
27967            NodeRequest::CatalogNativeSessions {
27968                route: unregistered_route.clone(),
27969                limit: 2,
27970            },
27971        )
27972        .await
27973        .unwrap();
27974        let external_json = serde_json::to_string(&external_catalog).unwrap();
27975        assert!(!external_json.contains("outside-session"));
27976        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27977        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27978        assert!(!external_json.contains("\"session_id\""));
27979        assert!(!external_json.contains("\"cwd\""));
27980        assert!(!external_json.contains("\"path\""));
27981        let NodeResponse::NativeSessionsCataloged {
27982            route: echoed_route,
27983            entries: external_entries,
27984            summary: Some(external_summary),
27985        } = external_catalog
27986        else {
27987            panic!("unregistered native catalog returned a different response");
27988        };
27989        assert_eq!(echoed_route, unregistered_route);
27990        assert_eq!(external_entries.len(), 1);
27991        assert_eq!(external_entries[0].record_id, None);
27992        let external_group = external_entries[0]
27993            .external_group
27994            .as_ref()
27995            .expect("unregistered session must have an external group");
27996        assert_eq!(
27997            external_group.kind,
27998            gate4agent_types::NativeSessionExternalGroupKind::Project,
27999        );
28000        assert!(external_group.group_id.starts_with("external-"));
28001        assert_eq!(
28002            external_group.display_name,
28003            outside_root.file_name().unwrap().to_string_lossy(),
28004        );
28005        let external_selection = NativeSessionSelection {
28006            route: unregistered_route,
28007            catalog_revision: external_summary.catalog_revision,
28008            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28009            selection_id: external_entries[0].selection_id.clone(),
28010        };
28011        let external_preview = process_request_inner(
28012            &shared,
28013            7,
28014            ClientRole::Operator,
28015            NodeRequest::PreviewNativeSession {
28016                selection: external_selection.clone(),
28017                message_limit: 1,
28018            },
28019        )
28020        .await
28021        .unwrap();
28022        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28023        assert!(!external_preview_json.contains("outside-session"));
28024        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28025        assert!(!external_preview_json.contains("\"session_id\""));
28026        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28027            panic!("unregistered native preview returned a different response");
28028        };
28029        assert_eq!(preview.messages.len(), 1);
28030        let external_index = process_request_inner(
28031            &shared,
28032            7,
28033            ClientRole::Operator,
28034            NodeRequest::IndexNativeSession {
28035                selection: external_selection,
28036                display_name: "External Claude".to_owned(),
28037            },
28038        )
28039        .await
28040        .unwrap_err();
28041        assert_eq!(
28042            external_index.code,
28043            NodeFailureCode::WorkspaceRegistrationRequired,
28044        );
28045        let record_response = process_request_inner(
28046            &shared,
28047            7,
28048            ClientRole::Operator,
28049            NodeRequest::PreviewSessionRecord {
28050                record_id: record.record_id.clone(),
28051                message_limit: 2,
28052            },
28053        )
28054        .await
28055        .unwrap();
28056        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28057            panic!("record preview returned a different response");
28058        };
28059        assert_eq!(record_id, record.record_id);
28060        assert_eq!(preview.messages.len(), 2);
28061        let encoded = serde_json::to_string(&preview).unwrap();
28062        assert!(!encoded.contains("stable-session"));
28063        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28064        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28065            panic!("record preview history summary returned a different response");
28066        };
28067        let summaries = events
28068            .iter()
28069            .filter_map(|envelope| match &envelope.event {
28070                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28071                    if record_id == &record.record_id =>
28072                {
28073                    Some(*summary)
28074                }
28075                _ => None,
28076            })
28077            .collect::<Vec<_>>();
28078        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28079        let summary_json = serde_json::to_string(&summaries).unwrap();
28080        for private in [
28081            "stable-session",
28082            "raw user text must not cross the catalog wire",
28083            "raw answer",
28084            "Workspace review",
28085            "claude-sonnet",
28086        ] {
28087            assert!(!summary_json.contains(private), "{private}");
28088        }
28089        let identity = record.provider_session.clone().unwrap();
28090        assert_eq!(
28091            shared.revalidate_session_record_preview(&record, &identity),
28092            Ok(())
28093        );
28094        shared.remove_record_memory(&record.record_id);
28095        assert_eq!(
28096            shared
28097                .revalidate_session_record_preview(&record, &identity)
28098                .unwrap_err()
28099                .code,
28100            NodeFailureCode::UnknownSessionRecord
28101        );
28102        let released = process_request_inner(
28103            &shared,
28104            7,
28105            ClientRole::Operator,
28106            NodeRequest::ReleaseController,
28107        )
28108        .await
28109        .unwrap();
28110        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28111        assert!(shared.controller_state().is_none());
28112        std::fs::remove_dir_all(workspace_root).unwrap();
28113        std::fs::remove_dir_all(history_root).unwrap();
28114        std::fs::remove_dir_all(outside_root).unwrap();
28115    }
28116
28117    #[tokio::test]
28118    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28119        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28120        std::fs::create_dir_all(&history_root).unwrap();
28121        let history = NativeHistoryConfig::new(vec![
28122            NativeHistoryRoot::new(
28123                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28124                HistorySourceLayout::SingleNdjson,
28125                history_root.clone(),
28126            )
28127            .unwrap(),
28128        ])
28129        .unwrap();
28130        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28131        let held = catalog.lock().unwrap();
28132        let result = timeout(
28133            Duration::from_secs(1),
28134            run_native_session_catalog_operation(
28135                Arc::clone(&catalog),
28136                "native session catalog",
28137                |_| Ok::<(), std::convert::Infallible>(()),
28138            ),
28139        )
28140        .await
28141        .expect("concurrent native catalog request must not wait for the operation deadline")
28142        .unwrap_err();
28143        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28144        drop(held);
28145        std::fs::remove_dir_all(history_root).unwrap();
28146    }
28147
28148    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28149        let unique = std::time::SystemTime::now()
28150            .duration_since(std::time::UNIX_EPOCH)
28151            .unwrap()
28152            .as_nanos();
28153        std::env::temp_dir().join(format!(
28154            "gate4agent-node-{label}-{}-{unique}",
28155            std::process::id(),
28156        ))
28157    }
28158
28159    fn git_snapshot_for_parser() -> GitSnapshot {
28160        GitSnapshot {
28161            is_repository: true,
28162            branch: Some("main".to_owned()),
28163            status: Vec::new(),
28164            recent_commits: Vec::new(),
28165            worktrees: Vec::new(),
28166            managed_worktree: None,
28167            truncated: false,
28168            diagnostic: None,
28169        }
28170    }
28171
28172    #[tokio::test]
28173    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28174        let root = temporary_workspace_root("managed-git-scope");
28175        std::fs::create_dir_all(&root).unwrap();
28176        run_test_git(&root, &["init"]);
28177        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28178        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28179        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28180        run_test_git(&root, &["add", "seed.txt"]);
28181        run_test_git(&root, &["commit", "-m", "seed"]);
28182        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28183
28184        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28185        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28186        let catalog = active_registry().unwrap();
28187        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28188        let shared = NodeShared::new(
28189            handle,
28190            "fixture-token".to_owned(),
28191            NodeId::new("node-1").unwrap(),
28192            vec![workspace],
28193            vec![agent("claude")],
28194        );
28195        let mut lease = ManagedWorktreeLeaseRecord {
28196            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28197            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28198            workspace_id: workspace_id.clone(),
28199            profile_id: WorktreeProfileId::new("default").unwrap(),
28200            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28201            target_root: root.to_string_lossy().into_owned(),
28202            branch: "gate4agent/a".to_owned(),
28203            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28204            expected_head: None,
28205            retention: ManagedWorktreeRetention::Retain,
28206            state: ManagedWorktreeLeaseState::InUse,
28207            session_holders: vec![ManagedWorktreeSessionHolder {
28208                incarnation_id: shared.incarnation_id,
28209                instance_id: AgentInstanceId(7),
28210                generation: SessionGeneration(1),
28211            }],
28212            record_holders: Vec::new(),
28213            cleanup_failure: None,
28214            created_at_unix_ms: 1,
28215            updated_at_unix_ms: 1,
28216        };
28217        *shared
28218            .managed_worktrees
28219            .lock()
28220            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28221            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28222
28223        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28224        let scope = inspection.git.managed_worktree.unwrap();
28225        assert_eq!(scope.lease_id, lease.lease_id);
28226        assert_eq!(scope.branch, "gate4agent/a");
28227        assert_eq!(scope.active_session_count, 1);
28228
28229        lease.branch = "gate4agent/b".to_owned();
28230        *shared
28231            .managed_worktrees
28232            .lock()
28233            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28234            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28235        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28236        assert!(mismatch.git.managed_worktree.is_none());
28237        std::fs::remove_dir_all(root).unwrap();
28238    }
28239
28240    #[tokio::test]
28241    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28242        let root = temporary_workspace_root("workspace-file-write-controller");
28243        std::fs::create_dir_all(root.join("src")).unwrap();
28244        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28245        let workspace_id = WorkspaceId::new("primary").unwrap();
28246        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28247        let catalog = active_registry().unwrap();
28248        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28249        let shared = NodeShared::new(
28250            handle,
28251            "fixture-token".to_owned(),
28252            NodeId::new("node-1").unwrap(),
28253            vec![workspace],
28254            vec![agent("claude")],
28255        );
28256        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28257        let expected_revision = workspace_file_revision(b"old\n");
28258        let request = NodeRequest::WriteWorkspaceFile {
28259            workspace_id: workspace_id.clone(),
28260            path: path.clone(),
28261            expected_revision: expected_revision.clone(),
28262            text: "new\n".to_owned(),
28263        };
28264        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28265            .await
28266            .unwrap_err();
28267        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28268        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28269            .await
28270            .unwrap_err();
28271        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28272        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28273        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28274        let NodeResponse::WorkspaceFileWritten { file } = response else {
28275            panic!("workspace file write returned another response");
28276        };
28277        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28278        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28279        let stale = process_request_inner(
28280            &shared,
28281            77,
28282            ClientRole::Operator,
28283            NodeRequest::WriteWorkspaceFile {
28284                workspace_id,
28285                path,
28286                expected_revision,
28287                text: "lost update\n".to_owned(),
28288            },
28289        )
28290        .await
28291        .unwrap_err();
28292        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28293        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28294        std::fs::remove_dir_all(root).unwrap();
28295    }
28296
28297    #[tokio::test]
28298    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28299        let root = temporary_workspace_root("workspace-entry-create-controller");
28300        std::fs::create_dir_all(root.join("src")).unwrap();
28301        let workspace_id = WorkspaceId::new("primary").unwrap();
28302        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28303        let catalog = active_registry().unwrap();
28304        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28305        let shared = NodeShared::new(
28306            handle,
28307            "fixture-token".to_owned(),
28308            NodeId::new("node-1").unwrap(),
28309            vec![workspace],
28310            vec![agent("claude")],
28311        );
28312        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28313        let request = NodeRequest::CreateWorkspaceFile {
28314            workspace_id: workspace_id.clone(),
28315            path: path.clone(),
28316        };
28317        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28318            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28319        }));
28320        assert!(!request_uses_unnegotiated_capability(
28321            &request,
28322            &baseline_capabilities().unwrap(),
28323        ));
28324        assert!(request_uses_unnegotiated_capability(&request, &[]));
28325        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28326            .await
28327            .unwrap_err();
28328        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28329        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28330            .await
28331            .unwrap_err();
28332        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28333        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28334
28335        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28336            .await
28337            .unwrap();
28338        let NodeResponse::WorkspaceFileCreated { file } = response else {
28339            panic!("workspace file create returned another response");
28340        };
28341        assert_eq!(file.workspace_id, workspace_id);
28342        assert_eq!(file.path, path);
28343        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28344        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28345
28346        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28347            .await
28348            .unwrap_err();
28349        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28350
28351        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28352        let response = process_request_inner(
28353            &shared,
28354            77,
28355            ClientRole::Operator,
28356            NodeRequest::CreateWorkspaceDirectory {
28357                workspace_id: workspace_id.clone(),
28358                path: directory_path.clone(),
28359            },
28360        )
28361        .await
28362        .unwrap();
28363        assert_eq!(
28364            response,
28365            NodeResponse::WorkspaceDirectoryCreated {
28366                workspace_id: workspace_id.clone(),
28367                entry: WorkspaceEntry {
28368                    relative_path: directory_path,
28369                    kind: WorkspaceEntryKind::Directory,
28370                },
28371            },
28372        );
28373        assert!(root.join("src/nested").is_dir());
28374
28375        let missing_parent = process_request_inner(
28376            &shared,
28377            77,
28378            ClientRole::Operator,
28379            NodeRequest::CreateWorkspaceFile {
28380                workspace_id: workspace_id.clone(),
28381                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28382            },
28383        )
28384        .await
28385        .unwrap_err();
28386        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28387        let non_directory_parent = process_request_inner(
28388            &shared,
28389            77,
28390            ClientRole::Operator,
28391            NodeRequest::CreateWorkspaceFile {
28392                workspace_id,
28393                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28394            },
28395        )
28396        .await
28397        .unwrap_err();
28398        assert_eq!(
28399            non_directory_parent.code,
28400            NodeFailureCode::RepositoryParentNotDirectory,
28401        );
28402        std::fs::remove_dir_all(root).unwrap();
28403    }
28404
28405    #[tokio::test]
28406    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28407        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28408        std::fs::create_dir_all(root.join("src")).unwrap();
28409        #[cfg(windows)]
28410        let root = std::fs::canonicalize(root).unwrap();
28411        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28412        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28413        let barrier = Arc::new(std::sync::Barrier::new(2));
28414        let done = Arc::new(AtomicBool::new(false));
28415        let task_commit_state = Arc::clone(&commit_state);
28416        let task_barrier = Arc::clone(&barrier);
28417        let task_done = Arc::clone(&done);
28418        let task_root = root.clone();
28419        let task = tokio::task::spawn_blocking(move || {
28420            task_barrier.wait();
28421            let result = create_workspace_file_on_disk(
28422                &task_root,
28423                &path,
28424                &task_commit_state,
28425            );
28426            task_done.store(true, Ordering::Release);
28427            result
28428        });
28429
28430        let error = settle_workspace_entry_create(
28431            task,
28432            Arc::clone(&commit_state),
28433            Duration::from_millis(10),
28434            "fixture deadline",
28435            "fixture task failed",
28436        )
28437        .await
28438        .unwrap_err();
28439        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28440        assert_eq!(
28441            commit_state.load(Ordering::Acquire),
28442            WORKSPACE_ENTRY_CREATE_CANCELED,
28443        );
28444
28445        barrier.wait();
28446        timeout(Duration::from_secs(1), async {
28447            while !done.load(Ordering::Acquire) {
28448                tokio::task::yield_now().await;
28449            }
28450        })
28451        .await
28452        .expect("canceled blocking create did not settle");
28453        assert!(!root.join("src/late.rs").exists());
28454        std::fs::remove_dir_all(root).unwrap();
28455    }
28456}
28457
28458#[cfg(test)]
28459mod provisional_spawn_tests {
28460    use super::*;
28461    use std::sync::Arc;
28462
28463    fn shared() -> NodeShared {
28464        let catalog = active_registry().unwrap();
28465        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28466        let workspace = WorkspaceConfig::new(
28467            WorkspaceId::new("primary").unwrap(),
28468            std::env::current_dir().unwrap(),
28469        )
28470        .unwrap()
28471        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28472        NodeShared::new(
28473            handle,
28474            "fixture-token".to_owned(),
28475            NodeId::new("node-provisional-test").unwrap(),
28476            vec![workspace],
28477            vec![AgentId::new("claude").unwrap()],
28478        )
28479    }
28480
28481    /// Provisional draft map: sessionize by instance id only; 50 sequential
28482    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28483    #[test]
28484    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28485        let shared = shared();
28486        let ws = WorkspaceId::new("primary").unwrap();
28487        let mut ids = Vec::with_capacity(50);
28488        for i in 1u64..=50 {
28489            let id = AgentInstanceId(i);
28490            ids.push(id);
28491            let address = SessionAddress {
28492                workspace_id: ws.clone(),
28493                session: SessionKey {
28494                    instance_id: id,
28495                    generation: SessionGeneration(1),
28496                },
28497            };
28498            shared.mark_spawn_provisional(address);
28499        }
28500        assert_eq!(
28501            shared
28502                .provisional_spawns
28503                .lock()
28504                .unwrap_or_else(|p| p.into_inner())
28505                .len(),
28506            50
28507        );
28508        for id in ids.iter().step_by(2) {
28509            shared.sessionize_spawn(*id);
28510        }
28511        assert_eq!(
28512            shared
28513                .provisional_spawns
28514                .lock()
28515                .unwrap_or_else(|p| p.into_inner())
28516                .len(),
28517            25
28518        );
28519        assert!(shared.take_due_provisional_spawns().is_empty());
28520        {
28521            let mut map = shared
28522                .provisional_spawns
28523                .lock()
28524                .unwrap_or_else(|p| p.into_inner());
28525            let past = Instant::now() - Duration::from_secs(91);
28526            for (_, (_, at)) in map.iter_mut() {
28527                *at = past;
28528            }
28529        }
28530        let due = shared.take_due_provisional_spawns();
28531        assert_eq!(due.len(), 25);
28532        let due_ids: std::collections::BTreeSet<_> =
28533            due.iter().map(|a| a.session.instance_id.0).collect();
28534        let expected: std::collections::BTreeSet<_> =
28535            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28536        assert_eq!(due_ids, expected);
28537        assert!(shared
28538            .provisional_spawns
28539            .lock()
28540            .unwrap_or_else(|p| p.into_inner())
28541            .is_empty());
28542    }
28543
28544    /// Twenty parallel provisional marks must each get their own instance key;
28545    /// killing/sessionizing one id must not touch the others.
28546    #[test]
28547    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28548        let shared = Arc::new(shared());
28549        let ws = WorkspaceId::new("primary").unwrap();
28550        let mut handles = Vec::new();
28551        for i in 1u64..=20 {
28552            let shared = Arc::clone(&shared);
28553            let ws = ws.clone();
28554            handles.push(std::thread::spawn(move || {
28555                let id = AgentInstanceId(i);
28556                let address = SessionAddress {
28557                    workspace_id: ws,
28558                    session: SessionKey {
28559                        instance_id: id,
28560                        generation: SessionGeneration(1),
28561                    },
28562                };
28563                shared.mark_spawn_provisional(address);
28564                id
28565            }));
28566        }
28567        let mut ids = Vec::new();
28568        for h in handles {
28569            ids.push(h.join().unwrap());
28570        }
28571        ids.sort_by_key(|id| id.0);
28572        assert_eq!(ids.len(), 20);
28573        ids.dedup();
28574        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28575        assert_eq!(
28576            shared
28577                .provisional_spawns
28578                .lock()
28579                .unwrap_or_else(|p| p.into_inner())
28580                .len(),
28581            20
28582        );
28583        shared.sessionize_spawn(AgentInstanceId(7));
28584        let left: Vec<_> = shared
28585            .provisional_spawns
28586            .lock()
28587            .unwrap_or_else(|p| p.into_inner())
28588            .keys()
28589            .map(|id| id.0)
28590            .collect();
28591        assert!(!left.contains(&7));
28592        assert_eq!(left.len(), 19);
28593    }
28594}
28595
28596
28597#[cfg(unix)]
28598extern "C" {
28599    #[link_name = "geteuid"]
28600    fn libc_geteuid() -> u32;
28601}
28602
28603#[cfg(test)]
28604mod call_home_link_tests {
28605    use super::*;
28606
28607    #[test]
28608    fn call_home_backoff_is_capped_and_monotonic() {
28609        let mut last = Duration::ZERO;
28610        for attempt in 1..20 {
28611            let next = call_home_backoff(attempt);
28612            assert!(next >= last);
28613            assert!(next <= Duration::from_secs(8));
28614            last = next;
28615        }
28616        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28617    }
28618
28619    #[tokio::test]
28620    async fn call_home_socket_gets_keepalive() {
28621        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28622        let addr = listener.local_addr().unwrap();
28623        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28624        let client = client.unwrap();
28625        enable_call_home_keepalive(&client).unwrap();
28626        let sock = socket2::SockRef::from(&client);
28627        assert!(sock.keepalive().unwrap());
28628        #[cfg(any(target_os = "linux", target_os = "macos"))]
28629        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28630    }
28631}
28632
28633/// A provider transcript for this session id exists in a known provider home.
28634fn provider_session_exists(provider: &str, id: &str) -> bool {
28635    if provider != "claude" { return false; }
28636    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28637    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28638    let name = format!("{id}.jsonl");
28639    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())))
28640}
28641
28642/// Append launch args to a prepared overlay (or create an instance overlay for them).
28643fn overlay_with_extra_args(
28644    provider: &AgentId,
28645    mode: SessionMode,
28646    overlay: Option<PreparedNativeLaunchOverlay>,
28647    args: Vec<OsString>,
28648) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28649    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28650    Ok(match (overlay, args) {
28651        (prepared, args) if args.is_empty() => prepared,
28652        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28653        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28654        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28655            provider.clone(),
28656            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28657            Vec::new(),
28658            args,
28659        ).map_err(|_| bad())?)),
28660    })
28661}
28662
28663/// Log a provider-error decision; escalations and limits go to core as session signals.
28664fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28665    use crate::provider_error::Action;
28666    match act {
28667        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"),
28668        Action::Limit { found } => {
28669            tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28670            if via == "acp" {
28671                crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28672            }
28673        }
28674        Action::Escalate { found, attempts } => {
28675            tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28676            crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28677        }
28678    }
28679}
28680
28681/// Turn trace in the node log for ACP sessions (start, tools, permission asks, end, errors):
28682/// names and sizes only, never prompt, tool input or output text.
28683fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28684    let id = address.session.instance_id.0;
28685    let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28686    match e {
28687        ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28688        ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28689        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"),
28690        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"),
28691        ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28692        ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28693        ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28694        _ => {}
28695    }
28696}